#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Labelme 训练集成插件
在 Labelme 界面中添加训练触发按钮和相关功能
"""

import os
import sys
import json
import subprocess
import threading
from pathlib import Path
from typing import Dict, Optional, List
from datetime import datetime

try:
    from PyQt5.QtWidgets import (
        QWidget, QVBoxLayout, QHBoxLayout, QPushButton, 
        QComboBox, QLabel, QMessageBox, QDialog, QLineEdit,
        QTextEdit, QProgressBar, QDockWidget, QGroupBox, QFileDialog
    )
    from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QPointF
    from PyQt5.QtGui import QIcon, QFont, QPixmap
except ImportError:
    print("警告: PyQt5 未安装，插件功能不可用")
    QWidget = None

# 导入工具类（使用适配器模式，确保系统随时可用）
try:
    from utils.conda_utils import CondaUtils
    USE_NEW_UTILS = True
except ImportError:
    # 如果工具类导入失败，使用旧实现
    USE_NEW_UTILS = False
    CondaUtils = None


class EvaluationThread(QThread):
    """评估线程，在后台执行评估任务"""
    
    status_updated = pyqtSignal(str)  # 状态更新信号
    progress_updated = pyqtSignal(int)  # 进度更新信号
    finished = pyqtSignal(bool, str)  # 完成信号 (success, message)
    
    def __init__(self, config: Dict):
        """
        初始化评估线程
        
        Args:
            config: 评估配置字典
        """
        super().__init__()
        self.config = config
        self.process = None
        self.is_running = False
    
    def run(self):
        """执行评估"""
        try:
            self.is_running = True
            
            # 打印评估配置
            print("\n" + "="*80)
            print("📋 评估配置详情")
            print("="*80)
            print(f"模型: {self.config.get('model', 'N/A')}")
            print(f"配置文件: {self.config.get('config_path', 'N/A')}")
            print(f"数据集目录: {self.config.get('dataset_dir', 'N/A')}")
            print(f"工作目录: {self.config.get('work_dir', 'N/A')}")
            print(f"验证文件: {self.config.get('val_file', 'N/A')}")
            print(f"评估设备: {self.config.get('device', 'N/A')}")
            print("="*80 + "\n")
            
            self.status_updated.emit("准备评估环境...")
            print("[评估] 准备评估环境...")
            
            # 检查验证文件是否存在
            val_file = Path(self.config.get('val_file', ''))
            if not val_file.exists():
                error_msg = f"验证文件不存在: {val_file}\n\n请确保已生成 instance_val.json 文件"
                print(f"\n[评估] ✗ {error_msg}")
                self.status_updated.emit(f"验证文件不存在: {val_file}")
                self.finished.emit(False, error_msg)
                return
            
            # 构建评估命令
            cmd = self._build_evaluation_command()
            
            # 打印实际执行的命令
            print("\n" + "="*80)
            print("🔧 执行评估命令")
            print("="*80)
            print(f"工作目录: {self.config.get('work_dir', 'N/A')}")
            print(f"完整命令:")
            if len(cmd) >= 2 and cmd[0] == 'bash' and cmd[1].startswith('-c'):
                shell_cmd = cmd[1][3:] if cmd[1].startswith('-c ') else cmd[1]
                parts = shell_cmd.split(' && ')
                for i, part in enumerate(parts, 1):
                    print(f"  {i}. {part.strip()}")
                print(f"\n完整可执行脚本:")
                print(f"  bash -c \"{shell_cmd}\"")
            else:
                print(f"  {' '.join(cmd)}")
            print("="*80 + "\n")
            
            self.status_updated.emit(f"启动评估进程...")
            print("[评估] 启动评估进程...")
            print("[评估] 评估输出:")
            print("-" * 80)
            
            # 执行评估（在 seal_env 环境中）
            self.process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                universal_newlines=True,
                shell=False
            )
            
            # 实时读取输出
            line_count = 0
            for line in iter(self.process.stdout.readline, ''):
                if not self.is_running:
                    break
                line = line.strip()
                if line:
                    line_count += 1
                    self.status_updated.emit(line)
                    print(f"[评估] {line}")
                    # 尝试解析进度信息
                    self._parse_progress(line)
            
            print("-" * 80)
            
            # 等待完成
            return_code = self.process.wait()
            
            print(f"\n[评估] 评估进程退出码: {return_code}")
            print(f"[评估] 共输出 {line_count} 行日志")
            
            if return_code == 0:
                print("[评估] ✓ 评估完成！")
                self.finished.emit(True, "评估完成！")
            else:
                print(f"\n[评估] ✗ 评估失败，退出码: {return_code}")
                error_msg = f"评估失败，退出码: {return_code}\n\n"
                error_msg += f"工作目录: {self.config.get('work_dir', 'N/A')}\n"
                error_msg += f"配置文件: {self.config.get('config_path', 'N/A')}\n"
                error_msg += f"数据集: {self.config.get('dataset_dir', 'N/A')}\n"
                error_msg += f"验证文件: {self.config.get('val_file', 'N/A')}\n"
                self.finished.emit(False, error_msg)
                
        except Exception as e:
            print(f"\n[评估] ✗ 评估异常: {str(e)}")
            import traceback
            traceback.print_exc()
            self.finished.emit(False, f"评估异常: {str(e)}")
        finally:
            self.is_running = False
            print("\n" + "="*80)
            print("[评估] 评估线程结束")
            print("="*80 + "\n")
    
    def _build_evaluation_command(self) -> List[str]:
        """构建评估命令（在 seal_env 环境中执行）"""
        main_py = self.config.get('main_py', 'main.py')
        config_path = self.config.get('config_path')
        dataset_dir = self.config.get('dataset_dir')
        device = self.config.get('device', 'gpu:0')
        work_dir = self.config.get('work_dir', '.')
        weight_path = self.config.get('weight_path', '')  # 可选，默认使用配置文件中的路径
        
        # 获取 conda 路径
        conda_path = self._get_conda_path()
        
        # 检查 config_path 是否为绝对路径
        config_path_abs = Path(config_path)
        if not config_path_abs.is_absolute():
            config_path_final = config_path
        else:
            config_path_final = str(config_path_abs)
        
        # 数据集路径处理：与训练命令保持一致，使用绝对路径
        # 训练命令直接使用绝对路径 dataset_dir，评估命令也应该使用绝对路径
        # 这样可以避免相对路径解析错误的问题
        dataset_dir_path = Path(dataset_dir)
        
        # 确保使用绝对路径
        if dataset_dir_path.is_absolute():
            dataset_dir_final = str(dataset_dir_path)
            print(f"[评估] 数据集路径（绝对路径）: {dataset_dir_final}")
        else:
            # 如果是相对路径，转换为绝对路径（相对于工作目录）
            dataset_dir_abs = (Path(work_dir) / dataset_dir).resolve()
            dataset_dir_final = str(dataset_dir_abs)
            print(f"[评估] 数据集路径（转换为绝对路径）: {dataset_dir_final}")
        
        # 构建 Python 命令参数（与用户提供的脚本格式一致）
        python_cmd_parts = [
            f'python {main_py}',
            f'-c {config_path_final}',
            f'-o Global.mode=evaluate',
            f'-o Global.device={device}',
            f'-o Global.dataset_dir={dataset_dir_final}',
        ]
        
        # 如果指定了权重路径，添加权重参数（使用相对路径）
        if weight_path:
            python_cmd_parts.append(f"-o Evaluate.weight_path={weight_path}")
        
        python_cmd = ' '.join(python_cmd_parts)
        
        # 打印评估命令详情
        print(f"\n[评估] 评估命令详情:")
        print(f"  工作目录: {work_dir}")
        print(f"  主程序: {main_py}")
        print(f"  配置文件: {config_path_final}")
        print(f"  数据集目录: {dataset_dir_final}")
        print(f"  评估设备: {device}")
        if weight_path:
            print(f"  权重路径: {weight_path}")
        print(f"  完整 Python 命令: {python_cmd}")
        
        # 构建完整的 shell 命令（在 seal_env 环境中执行）
        shell_cmd = f"""
        source {conda_path}/etc/profile.d/conda.sh && \
        conda activate seal_env && \
        cd {work_dir} && \
        {python_cmd}
        """
        
        return ['bash', '-c', shell_cmd.strip()]
    
    def _parse_progress(self, line: str):
        """解析进度信息"""
        # 评估通常没有明确的进度百分比，但可以尝试解析一些关键信息
        if 'mAP' in line or 'AP' in line or 'accuracy' in line.lower():
            # 如果找到评估指标，可以更新状态
            pass
    
    def _get_conda_path(self) -> str:
        """获取 conda 路径（使用 CondaUtils 工具类）"""
        if USE_NEW_UTILS and CondaUtils:
            try:
                return CondaUtils.get_conda_path()
            except Exception as e:
                print(f"⚠ 使用 CondaUtils 失败: {e}，使用默认路径")
        
        # 简单回退：返回默认路径
        return os.path.join(os.path.expanduser('~'), 'miniconda3')
    
    def stop(self):
        """停止评估"""
        self.is_running = False
        if self.process:
            self.process.terminate()
            try:
                self.process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.process.kill()


class InferenceThread(QThread):
    """推理线程，在后台执行推理任务（在 seal_env 环境中）"""
    
    status_updated = pyqtSignal(str)  # 状态更新信号
    finished = pyqtSignal(object, str)  # 完成信号 (ocr_result, message)
    
    def __init__(self, file_path: Path, work_dir: Path, config_path: str, device: str = "cpu"):
        """
        初始化推理线程
        
        Args:
            file_path: 要识别的文件路径
            work_dir: PaddleX 工作目录
            config_path: 配置文件路径
            device: 推理设备（cpu 或 gpu:0）
        """
        super().__init__()
        self.file_path = file_path
        self.work_dir = work_dir
        self.config_path = config_path
        self.device = device
        self.process = None
        self.is_running = False
    
    def run(self):
        """执行推理"""
        try:
            self.is_running = True
            
            # 构建推理命令
            cmd = self._build_inference_command()
            
            # 打印推理命令详情
            print("\n" + "="*80)
            print("🔍 推理配置详情")
            print("="*80)
            print(f"工作目录: {self.work_dir}")
            print(f"配置文件: {self.config_path}")
            print(f"输入文件: {self.file_path}")
            print(f"推理设备: {self.device}")
            print(f"输出目录: out_inference")
            print("="*80)
            print("🔧 执行推理命令")
            print("="*80)
            print(f"工作目录: {self.work_dir}")
            print(f"完整命令:")
            if len(cmd) >= 2 and cmd[0] == 'bash' and cmd[1].startswith('-c'):
                shell_cmd = cmd[1][3:] if cmd[1].startswith('-c ') else cmd[1]
                parts = shell_cmd.split(' && ')
                for i, part in enumerate(parts, 1):
                    print(f"  {i}. {part.strip()}")
                print(f"\n完整可执行脚本:")
                print(f"  bash -c \"{shell_cmd}\"")
            else:
                print(f"  {' '.join(cmd)}")
            print("="*80)
            print("📋 Python 命令参数:")
            print(f"  - python main.py")
            print(f"  - -c {self.config_path}")
            print(f"  - -o Predict.model_dir=output_last/best_model/inference")
            print(f"  - -o Global.mode=predict")
            print(f"  - -o Global.device={self.device}")
            print(f"  - -o Global.output=out_inference")
            print(f"  - -o Predict.input={self.file_path}")
            print("="*80 + "\n")
            
            self.status_updated.emit("启动推理进程...")
            
            # 执行推理（在 seal_env 环境中）
            self.process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                universal_newlines=True,
                shell=False,
                cwd=str(self.work_dir)
            )
            
            # 实时读取输出
            print("[推理] 推理输出:")
            print("-" * 80)
            line_count = 0
            for line in iter(self.process.stdout.readline, ''):
                if not self.is_running:
                    break
                line = line.strip()
                if line:
                    line_count += 1
                    self.status_updated.emit(line)
                    print(f"[推理] {line}")
            
            print("-" * 80)
            
            # 等待完成
            return_code = self.process.wait()
            
            print(f"\n[推理] 推理进程退出码: {return_code}")
            print(f"[推理] 共输出 {line_count} 行日志")
            
            if return_code == 0:
                print("[推理] ✓ 推理完成！")
                # 读取推理结果（返回原始JSON数据）
                result_data = self._read_inference_result()
                if result_data:
                    print(f"[推理] ✓ 成功读取推理结果")
                    self.finished.emit(result_data, "推理完成")
                else:
                    print(f"[推理] ⚠ 推理完成但无法读取结果文件")
                    self.finished.emit(None, "推理完成但无法读取结果")
            else:
                print(f"\n[推理] ✗ 推理失败，退出码: {return_code}")
                error_msg = f"推理失败，退出码: {return_code}\n\n"
                error_msg += f"工作目录: {self.work_dir}\n"
                error_msg += f"配置文件: {self.config_path}\n"
                error_msg += f"输入文件: {self.file_path}\n"
                error_msg += f"推理设备: {self.device}\n"
                self.finished.emit(None, error_msg)
                
        except Exception as e:
            print(f"\n[推理] ✗ 推理异常: {str(e)}")
            import traceback
            traceback.print_exc()
            self.finished.emit(None, f"推理异常: {str(e)}")
        finally:
            self.is_running = False
            print("\n" + "="*80)
            print("[推理] 推理线程结束")
            print("="*80 + "\n")
    
    def _build_inference_command(self) -> List[str]:
        """构建推理命令（在 seal_env 环境中执行）"""
        # 获取 conda 路径
        conda_path = self._get_conda_path()
        
        # 检查 config_path 是否为绝对路径
        config_path_abs = Path(self.config_path)
        if not config_path_abs.is_absolute():
            # 相对路径，相对于 work_dir
            config_path_final = self.config_path
        else:
            config_path_final = str(config_path_abs)
        
        # 构建输出目录（在工作目录下）
        output_dir = "out_inference"
        
        # 构建 Python 命令参数
        python_cmd_parts = [
            'python main.py',
            f'-c {config_path_final}',
            f'-o Predict.model_dir=output_last/best_model/inference',
            f'-o Global.mode=predict',
            f'-o Global.device={self.device}',
            f'-o Global.output={output_dir}',
            f'-o Predict.input={self.file_path}'
        ]
        
        python_cmd = ' '.join(python_cmd_parts)
        
        # 构建完整的 shell 命令
        shell_cmd = f"""
        source {conda_path}/etc/profile.d/conda.sh && \
        conda activate seal_env && \
        cd {self.work_dir} && \
        {python_cmd}
        """
        
        return ['bash', '-c', shell_cmd.strip()]
    
    def _read_inference_result(self) -> Optional[Dict]:
        """读取推理结果JSON（返回原始数据，在主线程中转换）
        
        注意：对于多页PDF，PaddleX可能返回多个结果文件或一个包含多页结果的文件
        """
        try:
            # 推理结果保存在 out_inference 目录下
            output_dir = self.work_dir / "out_inference"
            
            # 查找结果JSON文件
            # PaddleX predict 输出格式：out_inference/{filename}_res.json
            # 对于多页PDF，可能生成多个文件：{filename}_page_1_res.json, {filename}_page_2_res.json 等
            file_stem = self.file_path.stem
            
            # 优先查找第一页的单独结果文件（多页PDF时，PaddleX可能为每页生成单独文件）
            # 格式可能是：{filename}_0_res.json 或 {filename}_page_0_res.json
            page0_json = output_dir / f"{file_stem}_0_res.json"
            if not page0_json.exists():
                page0_json = output_dir / f"{file_stem}_page_0_res.json"
            
            # 如果找到第一页的单独文件，优先使用
            if page0_json.exists():
                result_json = page0_json
                print(f"✓ 找到第一页结果文件: {result_json.name}")
            else:
                # 尝试查找单文件结果（可能包含所有页）
                result_json = output_dir / f"{file_stem}_res.json"
                
                # 如果没找到，尝试查找第一页的其他命名格式
                if not result_json.exists():
                    # 尝试查找第一页：{filename}_page_1_res.json（注意：PaddleX可能从1开始计数）
                    page1_json = output_dir / f"{file_stem}_page_1_res.json"
                    if page1_json.exists():
                        result_json = page1_json
                        print(f"✓ 找到第一页结果文件（page_1格式）: {result_json.name}")
                    else:
                        # 查找所有JSON文件
                        json_files = list(output_dir.glob("*_res.json"))
                        if json_files:
                            # 优先选择包含文件名且页码最小的（0或1）
                            matching_files = [f for f in json_files if file_stem in f.stem]
                            if matching_files:
                                # 按文件名排序，优先选择包含0或1的
                                matching_files.sort()
                                # 优先选择包含"_0_"或"_page_0_"的文件
                                for f in matching_files:
                                    if "_0_" in f.stem or "_page_0_" in f.stem:
                                        result_json = f
                                        print(f"✓ 找到第一页结果文件（包含0）: {result_json.name}")
                                        break
                                else:
                                    # 如果没有找到包含0的，选择第一个
                                    result_json = matching_files[0]
                                    print(f"⚠ 使用第一个匹配文件: {result_json.name}（可能不是第一页）")
                            else:
                                result_json = json_files[0]
                                print(f"⚠ 使用第一个JSON文件: {result_json.name}（可能不是第一页）")
                        else:
                            print(f"⚠ 未找到推理结果文件: {output_dir}")
                            return None
                else:
                    print(f"✓ 找到单文件结果: {result_json.name}（可能包含多页，将过滤第一页）")
            
            # 读取JSON文件
            with open(result_json, 'r', encoding='utf-8') as f:
                content = f.read().strip()
            
            # 处理可能的多种格式：
            # 1. 单个JSON对象：{"res": {...}}
            # 2. 多个JSON对象（每行一个）：{"res": {...}}\n{"res": {...}}
            # 3. JSON数组：[{"res": {...}}, {"res": {...}}]
            
            result_data = None
            try:
                # 先尝试作为单个JSON对象解析
                result_data = json.loads(content)
            except json.JSONDecodeError:
                # 如果不是单个JSON，尝试按行解析（每行一个JSON对象）
                lines = content.split('\n')
                results = []
                for line in lines:
                    line = line.strip()
                    if line:
                        try:
                            obj = json.loads(line)
                            results.append(obj)
                        except json.JSONDecodeError:
                            continue
                
                if results:
                    # 如果有多行结果，返回列表格式
                    if len(results) == 1:
                        result_data = results[0]
                    else:
                        result_data = results
                else:
                    print(f"⚠ 无法解析JSON文件: {result_json}")
                    return None
            
            if result_data:
                print(f"✓ 读取推理结果成功，文件: {result_json.name}, 格式: {type(result_data).__name__}")
                # 如果是字典且包含res，打印基本信息
                if isinstance(result_data, dict) and 'res' in result_data:
                    res = result_data['res']
                    boxes_count = len(res.get('boxes', []))
                    page_index = res.get('page_index', 'N/A')
                    print(f"  页索引: {page_index}, 检测框数量: {boxes_count}")
                elif isinstance(result_data, list):
                    print(f"  包含 {len(result_data)} 个结果")
                    # 打印每个结果的page_index
                    for idx, item in enumerate(result_data):
                        if isinstance(item, dict):
                            if 'res' in item:
                                page_idx = item['res'].get('page_index', 'N/A')
                                boxes_cnt = len(item['res'].get('boxes', []))
                                print(f"    结果[{idx}]: page_index={page_idx}, boxes={boxes_cnt}")
                            elif 'page_index' in item:
                                page_idx = item.get('page_index', 'N/A')
                                boxes_cnt = len(item.get('boxes', []))
                                print(f"    结果[{idx}]: page_index={page_idx}, boxes={boxes_cnt}")
            
            return result_data
            
        except Exception as e:
            print(f"⚠ 读取推理结果失败: {e}")
            import traceback
            traceback.print_exc()
            return None
    
    def _get_conda_path(self) -> str:
        """获取 conda 路径（使用 CondaUtils 工具类）"""
        if USE_NEW_UTILS and CondaUtils:
            try:
                return CondaUtils.get_conda_path()
            except Exception as e:
                print(f"⚠ 使用 CondaUtils 失败: {e}，使用默认路径")
        
        # 简单回退：返回默认路径
        return os.path.join(os.path.expanduser('~'), 'miniconda3')
    
    def stop(self):
        """停止推理"""
        self.is_running = False
        if self.process:
            self.process.terminate()
            try:
                self.process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.process.kill()


class TrainingThread(QThread):
    """训练线程，在后台执行训练任务"""
    
    status_updated = pyqtSignal(str)  # 状态更新信号
    progress_updated = pyqtSignal(int)  # 进度更新信号
    finished = pyqtSignal(bool, str)  # 完成信号 (success, message)
    
    def __init__(self, config: Dict):
        """
        初始化训练线程
        
        Args:
            config: 训练配置字典
        """
        super().__init__()
        self.config = config
        self.process = None
        self.is_running = False
    
    def run(self):
        """执行训练"""
        try:
            self.is_running = True
            
            # 打印训练配置
            print("\n" + "="*80)
            print("📋 训练配置详情")
            print("="*80)
            print(f"模型: {self.config.get('model', 'N/A')}")
            print(f"配置文件: {self.config.get('config_path', 'N/A')}")
            print(f"数据集目录: {self.config.get('dataset_dir', 'N/A')}")
            print(f"工作目录: {self.config.get('work_dir', 'N/A')}")
            print(f"输出目录: {self.config.get('output_dir', 'N/A')}")
            print(f"训练轮数: {self.config.get('epochs', 'N/A')}")
            print(f"训练设备: {self.config.get('device', 'N/A')}")
            print(f"主程序: {self.config.get('main_py', 'N/A')}")
            print("="*80 + "\n")
            
            self.status_updated.emit("准备训练环境...")
            print("[训练] 准备训练环境...")
            
            # 检查训练环境依赖
            missing_deps = self._check_training_dependencies()
            if missing_deps:
                error_msg = f"训练环境缺少以下依赖: {', '.join(missing_deps)}\n\n"
                
                # 提供详细的安装建议
                install_commands = []
                if 'paddlex' in missing_deps:
                    install_commands.append("  pip install 'paddlex[ocr]==3.2.1' -i https://pypi.tuna.tsinghua.edu.cn/simple")
                    install_commands.append("  pip install backports.zoneinfo -i https://pypi.tuna.tsinghua.edu.cn/simple  # Python 3.8 必需")
                if 'colorlog' in missing_deps:
                    install_commands.append("  pip install colorlog -i https://pypi.tuna.tsinghua.edu.cn/simple")
                
                if install_commands:
                    error_msg += "请运行以下命令安装:\n"
                    error_msg += "  conda activate seal_env\n"
                    for cmd in install_commands:
                        error_msg += f"{cmd}\n"
                else:
                    error_msg += "请运行以下命令安装:\n"
                    error_msg += f"  conda activate seal_env\n"
                    error_msg += f"  pip install {' '.join(missing_deps)}\n"
                
                print(f"\n[训练] ✗ {error_msg}")
                self.status_updated.emit(f"缺少依赖: {', '.join(missing_deps)}")
                self.finished.emit(False, error_msg)
                return
            
            # 构建训练命令
            cmd = self._build_training_command()
            
            # 打印实际执行的命令
            print("\n" + "="*80)
            print("🔧 执行训练命令")
            print("="*80)
            print(f"工作目录: {self.config.get('work_dir', 'N/A')}")
            print(f"完整命令:")
            # 解析 shell 命令以便更好地显示
            if len(cmd) >= 2 and cmd[0] == 'bash' and cmd[1].startswith('-c'):
                # 提取实际的 shell 命令
                shell_cmd = cmd[1][3:] if cmd[1].startswith('-c ') else cmd[1]
                # 按 && 分割，更清晰地显示
                parts = shell_cmd.split(' && ')
                for i, part in enumerate(parts, 1):
                    print(f"  {i}. {part.strip()}")
                # 打印完整的可执行脚本（用于调试）
                print(f"\n完整可执行脚本:")
                print(f"  bash -c \"{shell_cmd}\"")
            else:
                print(f"  {' '.join(cmd)}")
            print("="*80 + "\n")
            
            self.status_updated.emit(f"启动训练进程: {' '.join(cmd)}")
            print("[训练] 启动训练进程...")
            print("[训练] 训练输出:")
            print("-" * 80)
            
            # 执行训练（在 seal_env 环境中）
            # 注意：work_dir 已经在 shell 命令中通过 cd 切换了
            self.process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                universal_newlines=True,
                shell=False  # 使用 bash -c，所以 shell=False
            )
            
            # 实时读取输出
            line_count = 0
            for line in iter(self.process.stdout.readline, ''):
                if not self.is_running:
                    break
                line = line.strip()
                if line:
                    line_count += 1
                    # 同时输出到界面和命令行
                    self.status_updated.emit(line)
                    print(f"[训练] {line}")
                    # 尝试解析进度信息
                    self._parse_progress(line)
            
            print("-" * 80)
            
            # 等待完成
            return_code = self.process.wait()
            
            print(f"\n[训练] 训练进程退出码: {return_code}")
            print(f"[训练] 共输出 {line_count} 行日志")
            
            if return_code == 0:
                print("[训练] ✓ 训练完成！")
                self.finished.emit(True, "训练完成！")
            else:
                print(f"\n[训练] ✗ 训练失败，退出码: {return_code}")
                print(f"[训练] 详细错误信息:")
                print(f"  - 退出码: {return_code}")
                print(f"  - 工作目录: {self.config.get('work_dir', 'N/A')}")
                print(f"  - 配置文件: {self.config.get('config_path', 'N/A')}")
                print(f"  - 数据集目录: {self.config.get('dataset_dir', 'N/A')}")
                print(f"  - 训练设备: {self.config.get('device', 'N/A')}")
                print(f"  - 训练轮数: {self.config.get('epochs', 'N/A')}")
                print(f"\n[训练] 请检查:")
                print(f"  1. 数据集路径是否正确: {self.config.get('dataset_dir', 'N/A')}")
                print(f"  2. 配置文件是否存在: {self.config.get('config_path', 'N/A')}")
                print(f"  3. 训练数据文件是否存在 (instance_train.json, instance_val.json)")
                print(f"  4. 环境依赖是否完整 (PaddlePaddle, PaddleX, colorlog)")
                
                error_msg = f"训练失败，退出码: {return_code}\n\n"
                error_msg += f"工作目录: {self.config.get('work_dir', 'N/A')}\n"
                error_msg += f"配置文件: {self.config.get('config_path', 'N/A')}\n"
                error_msg += f"数据集: {self.config.get('dataset_dir', 'N/A')}\n"
                error_msg += "\n请检查上述路径和文件是否存在"
                
                self.finished.emit(False, error_msg)
                
        except Exception as e:
            print(f"\n[训练] ✗ 训练异常: {str(e)}")
            import traceback
            traceback.print_exc()
            self.finished.emit(False, f"训练异常: {str(e)}")
        finally:
            self.is_running = False
            print("\n" + "="*80)
            print("[训练] 训练线程结束")
            print("="*80 + "\n")
    
    def _build_training_command(self) -> List[str]:
        """构建训练命令（在 seal_env 环境中执行）"""
        # 获取 PaddleX 主程序路径
        main_py = self.config.get('main_py', 'main.py')
        config_path = self.config.get('config_path')
        dataset_dir = self.config.get('dataset_dir')
        epochs = self.config.get('epochs', 100)
        device = self.config.get('device', 'gpu:0')
        work_dir = self.config.get('work_dir', '.')
        
        # 获取 conda 路径
        conda_path = self._get_conda_path()
        
        # 构建在 seal_env 环境中执行的命令
        # 使用 bash -c 来激活 conda 环境并执行训练
        # 注意：config_path 可能是相对路径（相对于 work_dir）或绝对路径
        # 如果是相对路径，需要在 work_dir 中执行；如果是绝对路径，直接使用
        
        # 检查 config_path 是否为绝对路径
        config_path_abs = Path(config_path)
        if not config_path_abs.is_absolute():
            # 相对路径，相对于 work_dir
            config_path_final = config_path
        else:
            # 绝对路径，直接使用
            config_path_final = str(config_path_abs)
        
        # 构建 Python 命令参数
        python_cmd_parts = [
            f'python {main_py}',
            f'-c {config_path_final}',
            f'-o Global.mode=train',
            f'-o Global.dataset_dir={dataset_dir}',
            f'-o Global.device={device}',
            f'-o Train.epochs_iters={epochs}',
        ]
        
        if 'output_dir' in self.config:
            python_cmd_parts.append(f"-o Global.output={self.config['output_dir']}")
        
        python_cmd = ' '.join(python_cmd_parts)
        
        # 打印完整的训练命令（用于调试）
        print(f"[训练] 训练命令详情:")
        print(f"  工作目录: {work_dir}")
        print(f"  主程序: {main_py}")
        print(f"  配置文件: {config_path_final}")
        print(f"  数据集目录: {dataset_dir}")
        print(f"  训练设备: {device}")
        print(f"  训练轮数: {epochs}")
        if 'output_dir' in self.config:
            print(f"  输出目录: {self.config['output_dir']}")
        print(f"  完整 Python 命令: {python_cmd}")
        
        # 构建完整的 shell 命令
        # main.py 已经包含 zoneinfo 修复代码，直接使用即可
        shell_cmd = f"""
        source {conda_path}/etc/profile.d/conda.sh && \
        conda activate seal_env && \
        cd {work_dir} && \
        {python_cmd}
        """
        
        # 返回 bash 命令
        return ['bash', '-c', shell_cmd.strip()]
    
    def _check_training_dependencies(self) -> List[str]:
        """检查训练环境依赖，返回缺失的依赖列表"""
        missing_deps = []
        required_modules = ['colorlog', 'paddlex']
        
        conda_path = self._get_conda_path()
        seal_env_python = os.path.join(conda_path, 'envs', 'seal_env', 'bin', 'python')
        
        # 检查 seal_env 环境的 Python 是否存在
        if not os.path.exists(seal_env_python):
            # 尝试其他可能的路径
            home = os.path.expanduser('~')
            alt_paths = [
                os.path.join(home, 'miniconda3', 'envs', 'seal_env', 'bin', 'python'),
                os.path.join(home, 'anaconda3', 'envs', 'seal_env', 'bin', 'python'),
            ]
            for alt_path in alt_paths:
                if os.path.exists(alt_path):
                    seal_env_python = alt_path
                    break
            else:
                print(f"[训练] ⚠ 无法找到 seal_env 环境的 Python，跳过依赖检查")
                return []
        
        print(f"[训练] 检查依赖，使用 Python: {seal_env_python}")
        
        # 使用 seal_env 环境的 Python 检查依赖
        # 通过 conda activate 来确保使用正确的环境
        for module in required_modules:
            try:
                # 使用 conda activate 来确保环境正确
                conda_sh = os.path.join(conda_path, 'etc', 'profile.d', 'conda.sh')
                
                # 对于 paddlex，需要先修复 zoneinfo 问题（Python 3.8）
                if module == 'paddlex':
                    # 检查 Python 版本，如果是 3.8，需要修复 zoneinfo
                    version_check = f"""
                    source {conda_sh} && \
                    conda activate seal_env && \
                    python -c "import sys; print(f'{{sys.version_info.major}}.{{sys.version_info.minor}}')"
                    """
                    version_result = subprocess.run(
                        ['bash', '-c', version_check.strip()],
                        capture_output=True,
                        text=True,
                        timeout=5
                    )
                    python_version = version_result.stdout.strip() if version_result.returncode == 0 else "3.8"
                    
                    # Python 3.8 需要修复 zoneinfo
                    if python_version.startswith('3.8'):
                        check_cmd = f"""
                        source {conda_sh} && \
                        conda activate seal_env && \
                        python -c "import sys; import backports.zoneinfo; sys.modules['zoneinfo'] = backports.zoneinfo; import {module}; print('OK')"
                        """
                    else:
                        check_cmd = f"""
                        source {conda_sh} && \
                        conda activate seal_env && \
                        python -c "import {module}; print('OK')"
                        """
                else:
                    check_cmd = f"""
                    source {conda_sh} && \
                    conda activate seal_env && \
                    python -c "import {module}; print('OK')"
                    """
                
                result = subprocess.run(
                    ['bash', '-c', check_cmd.strip()],
                    capture_output=True,
                    text=True,
                    timeout=15
                )
                
                if result.returncode != 0 or 'OK' not in result.stdout:
                    missing_deps.append(module)
                    print(f"[训练] ⚠ 缺少依赖: {module}")
                    if result.stderr:
                        # 打印完整的错误信息
                        error_lines = result.stderr.strip().split('\n')
                        print(f"       错误信息:")
                        for line in error_lines[-10:]:  # 只显示最后10行，避免太长
                            print(f"         {line}")
                    if result.stdout and 'OK' not in result.stdout:
                        # 如果 stdout 中有错误信息，也打印
                        stdout_lines = result.stdout.strip().split('\n')
                        for line in stdout_lines[-5:]:
                            if line.strip() and 'OK' not in line:
                                print(f"         {line}")
                else:
                    print(f"[训练] ✓ 依赖检查通过: {module}")
            except subprocess.TimeoutExpired:
                print(f"[训练] ⚠ 检查依赖 {module} 超时")
                if module not in missing_deps:
                    missing_deps.append(module)
            except Exception as e:
                print(f"[训练] ⚠ 检查依赖 {module} 时出错: {e}")
                import traceback
                traceback.print_exc()
                # 如果检查失败，不假设依赖缺失（因为可能是环境问题，让训练继续尝试）
                # 只在明确失败时才添加
                pass
        
        return missing_deps
    
    def _get_conda_path(self) -> str:
        """获取 conda 安装路径（使用 CondaUtils 工具类）"""
        if USE_NEW_UTILS and CondaUtils:
            try:
                return CondaUtils.get_conda_path()
            except Exception as e:
                print(f"⚠ 使用 CondaUtils 失败: {e}，使用默认路径")
        
        # 简单回退：返回默认路径
        return os.path.join(os.path.expanduser('~'), 'miniconda3')
    
    def _parse_progress(self, line: str):
        """解析训练进度"""
        # 尝试从输出中提取进度信息
        # 例如: "Epoch 10/100" -> 10%
        if 'Epoch' in line and '/' in line:
            try:
                parts = line.split('Epoch')[1].strip().split('/')
                if len(parts) == 2:
                    current = int(parts[0].strip())
                    total = int(parts[1].strip().split()[0])
                    progress = int((current / total) * 100)
                    self.progress_updated.emit(progress)
            except:
                pass
    
    def stop(self):
        """停止训练"""
        self.is_running = False
        if self.process:
            self.process.terminate()
            try:
                self.process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.process.kill()


class TrainingConfigDialog(QDialog):
    """训练配置对话框"""
    
    def __init__(self, parent=None, default_config: Optional[Dict] = None):
        super().__init__(parent)
        self.setWindowTitle("训练配置")
        self.setModal(True)
        self.resize(500, 400)
        
        self.default_config = default_config or {}
        self.setup_ui()
        # 初始化时更新数据集路径显示（在 UI 创建后更新）
        self._update_dataset_path_display()
    
    def setup_ui(self):
        """设置 UI"""
        layout = QVBoxLayout()
        self.setLayout(layout)
        
        # 数据集配置
        version_group = QGroupBox("数据集配置")
        version_layout = QVBoxLayout()
        version_layout.addWidget(QLabel("数据集路径:"))
        # 先获取当前数据集路径，再设置标签
        current_dataset_dir = self._get_current_dataset_dir()
        self.dataset_label = QLabel(str(current_dataset_dir))
        self.dataset_label.setStyleSheet("color: gray;")
        self.dataset_label.setToolTip(str(current_dataset_dir))
        version_layout.addWidget(self.dataset_label)
        version_layout.addWidget(QLabel("(使用 Git 版本管理标注)"))
        version_group.setLayout(version_layout)
        layout.addWidget(version_group)
        
        # 模型配置
        model_group = QGroupBox("模型配置")
        model_layout = QVBoxLayout()
        model_layout.addWidget(QLabel("模型:"))
        self.model_combo = QComboBox()
        self.model_combo.addItems(["PP-DocLayout-L", "PP-DocLayout-M", "PP-DocLayout-S"])
        self.model_combo.currentTextChanged.connect(self._on_model_changed)
        model_layout.addWidget(self.model_combo)
        
        model_layout.addWidget(QLabel("配置文件路径:"))
        self.config_path_input = QLineEdit()
        self.config_path_input.setPlaceholderText("paddlex/configs/modules/layout_detection/PP-DocLayout-L.yaml")
        # 设置默认值
        self.config_path_input.setText("paddlex/configs/modules/layout_detection/PP-DocLayout-L.yaml")
        model_layout.addWidget(self.config_path_input)
        model_group.setLayout(model_layout)
        layout.addWidget(model_group)
        
        # 训练参数
        train_group = QGroupBox("训练参数")
        train_layout = QVBoxLayout()
        
        epochs_layout = QHBoxLayout()
        epochs_layout.addWidget(QLabel("训练轮数:"))
        self.epochs_input = QLineEdit(str(self.default_config.get('epochs', 100)))
        epochs_layout.addWidget(self.epochs_input)
        train_layout.addLayout(epochs_layout)
        
        device_layout = QHBoxLayout()
        device_layout.addWidget(QLabel("训练设备:"))
        self.device_combo = QComboBox()
        self.device_combo.addItems(["cpu", "gpu:0", "gpu:0,1"])
        device_layout.addWidget(self.device_combo)
        train_layout.addLayout(device_layout)
        
        train_group.setLayout(train_layout)
        layout.addWidget(train_group)
        
        # 按钮
        btn_layout = QHBoxLayout()
        ok_btn = QPushButton("开始训练")
        ok_btn.clicked.connect(self.accept)
        cancel_btn = QPushButton("取消")
        cancel_btn.clicked.connect(self.reject)
        btn_layout.addWidget(ok_btn)
        btn_layout.addWidget(cancel_btn)
        layout.addLayout(btn_layout)
    
    def _on_model_changed(self, model_name: str):
        """模型选择改变时更新配置文件路径"""
        config_path = f"paddlex/configs/modules/layout_detection/{model_name}.yaml"
        self.config_path_input.setText(config_path)
    
    def _update_dataset_path_display(self):
        """更新数据集路径显示"""
        # 获取当前数据集路径
        dataset_dir = self._get_current_dataset_dir()
        
        # 更新显示
        if hasattr(self, 'dataset_label'):
            self.dataset_label.setText(str(dataset_dir))
            self.dataset_label.setToolTip(str(dataset_dir))
    
    def _get_current_dataset_dir(self) -> Path:
        """获取当前数据集目录路径"""
        # 首先检查是否有保存的当前数据集路径（从插件实例）
        if hasattr(self, '_current_dataset_dir'):
            return Path(self._current_dataset_dir).resolve()
        
        # 尝试从父窗口获取 main_window 和插件实例
        main_window = None
        plugin_widget = None
        
        if hasattr(self.parent(), 'main_window'):
            main_window = self.parent().main_window
            plugin_widget = self.parent()
        elif hasattr(self.parent(), 'parent') and hasattr(self.parent().parent(), 'main_window'):
            main_window = self.parent().parent().main_window
            plugin_widget = self.parent().parent()
        
        # 如果找到了插件实例，尝试从插件实例获取当前数据集路径
        if plugin_widget and hasattr(plugin_widget, '_current_dataset_dir'):
            return Path(plugin_widget._current_dataset_dir).resolve()
        
        # 如果找到了 main_window，尝试获取当前打开目录
        if main_window:
            # 优先从当前打开的图片路径获取数据集目录
            if hasattr(main_window, 'filename') and main_window.filename:
                image_path = Path(main_window.filename).resolve()
                # 如果图片在 images 子目录中，使用父目录作为数据集目录
                if image_path.parent.name == 'images':
                    dataset_dir = image_path.parent.parent
                    return dataset_dir.resolve()
                # 否则，使用图片所在目录作为数据集目录
                else:
                    dataset_dir = image_path.parent
                    return dataset_dir.resolve()
        
        # 如果没有找到数据集目录，使用默认路径
        plugin_file = Path(__file__).resolve()
        project_root = plugin_file.parent.parent.parent
        dataset_dir = project_root / "paddleocr" / "dataset"
        return dataset_dir.resolve()
    
    def get_config(self) -> Dict:
        """获取配置"""
        model = self.model_combo.currentText()
        config_path_input = self.config_path_input.text() or f"paddlex/configs/modules/layout_detection/{model}.yaml"
        
        # 获取项目根目录（用于构建工作目录）
        plugin_file = Path(__file__).resolve()
        project_root = plugin_file.parent.parent.parent
        
        # 获取数据集路径 - 使用 _get_current_dataset_dir 方法
        dataset_dir = self._get_current_dataset_dir()
        
        # 构建工作目录和输出目录 - 优先使用 seal_env/PaddleX，如果不存在则使用 seal_env_backup/PaddleX
        work_dir = project_root / "paddleocr" / "seal_env" / "PaddleX"
        if not work_dir.exists():
            work_dir = project_root / "paddleocr" / "seal_env_backup" / "PaddleX"
        
        output_dir = "output_last"  # 相对于 work_dir 的路径
        
        # 确保路径是绝对路径
        work_dir = work_dir.resolve()
        
        # 更新数据集路径显示（使用实际获取到的路径）
        if hasattr(self, 'dataset_label'):
            self.dataset_label.setText(str(dataset_dir))
            self.dataset_label.setToolTip(str(dataset_dir))
        
        # 验证配置文件是否存在（相对于 work_dir）
        print(f"\n[配置] 开始解析配置文件路径...")
        print(f"[配置] 项目根目录: {project_root}")
        print(f"[配置] 工作目录: {work_dir}")
        print(f"[配置] 工作目录是否存在: {work_dir.exists()}")
        print(f"[配置] 配置文件输入: {config_path_input}")
        
        config_path_abs = work_dir / config_path_input
        print(f"[配置] 尝试路径 1 (相对路径): {config_path_abs}")
        print(f"[配置] 路径 1 是否存在: {config_path_abs.exists()}")
        
        if not config_path_abs.exists():
            # 尝试在 seal_env_backup 中查找
            backup_work_dir = project_root / "paddleocr" / "seal_env_backup" / "PaddleX"
            backup_config_path = backup_work_dir / config_path_input
            print(f"[配置] 尝试路径 2 (备份路径): {backup_config_path}")
            print(f"[配置] 路径 2 是否存在: {backup_config_path.exists()}")
            
            if backup_config_path.exists():
                work_dir = backup_work_dir.resolve()
                config_path = config_path_input  # 使用相对路径
                print(f"[配置] ✓ 在 seal_env_backup 中找到配置文件: {backup_config_path}")
            else:
                # 如果都找不到，尝试作为绝对路径
                config_path_abs_alt = Path(config_path_input)
                print(f"[配置] 尝试路径 3 (绝对路径): {config_path_abs_alt}")
                print(f"[配置] 路径 3 是否存在: {config_path_abs_alt.exists()}")
                print(f"[配置] 路径 3 是否为绝对路径: {config_path_abs_alt.is_absolute()}")
                
                if config_path_abs_alt.exists() and config_path_abs_alt.is_absolute():
                    config_path = config_path_input  # 使用绝对路径
                    print(f"[配置] ✓ 使用绝对路径配置文件: {config_path_abs_alt}")
                else:
                    # 如果都找不到，使用输入路径（可能是用户自定义路径，稍后验证）
                    config_path = config_path_input
                    print(f"[配置] ⚠ 配置文件未找到，将在验证时检查: {config_path_input}")
                    print(f"[配置]   尝试的路径 1: {config_path_abs}")
                    print(f"[配置]   尝试的路径 2: {backup_config_path}")
                    print(f"[配置]   尝试的路径 3: {config_path_abs_alt}")
        else:
            config_path = config_path_input  # 使用相对路径（相对于 work_dir）
            print(f"[配置] ✓ 找到配置文件: {config_path_abs}")
        
        # 验证数据集目录和标注文件
        annotations_dir = dataset_dir / "annotations"
        train_file = annotations_dir / "instance_train.json"
        val_file = annotations_dir / "instance_val.json"
        
        if not annotations_dir.exists():
            from PyQt5.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "警告",
                f"标注目录不存在: {annotations_dir}\n请确保数据集目录结构正确。"
            )
        elif not train_file.exists() and not val_file.exists():
            from PyQt5.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "警告",
                f"未找到训练数据文件:\n- {train_file}\n- {val_file}\n\n请确保至少有一个标注文件存在。"
            )
        
        return {
            "model": model,
            "config_path": config_path,  # 相对于 work_dir 的路径
            "dataset_dir": str(dataset_dir),
            "epochs": int(self.epochs_input.text()),
            "device": self.device_combo.currentText(),
            "output_dir": output_dir,
            "work_dir": str(work_dir),
            "main_py": "main.py",
        }


class EvaluationConfigDialog(QDialog):
    """评估配置对话框"""
    
    def __init__(self, parent=None, default_config: Optional[Dict] = None):
        super().__init__(parent)
        self.setWindowTitle("评估配置")
        self.setModal(True)
        self.resize(500, 450)
        
        self.default_config = default_config or {}
        self.setup_ui()
        # 初始化时更新数据集路径显示（在 UI 创建后更新）
        self._update_dataset_path_display()
    
    def setup_ui(self):
        """设置 UI"""
        layout = QVBoxLayout()
        self.setLayout(layout)
        
        # 数据集配置
        version_group = QGroupBox("数据集配置")
        version_layout = QVBoxLayout()
        version_layout.addWidget(QLabel("数据集路径:"))
        # 先获取当前数据集路径，再设置标签
        current_dataset_dir = self._get_current_dataset_dir()
        self.dataset_label = QLabel(str(current_dataset_dir))
        self.dataset_label.setStyleSheet("color: gray;")
        self.dataset_label.setToolTip(str(current_dataset_dir))
        version_layout.addWidget(self.dataset_label)
        version_layout.addWidget(QLabel("(使用 Git 版本管理标注)"))
        version_group.setLayout(version_layout)
        layout.addWidget(version_group)
        
        # 模型配置
        model_group = QGroupBox("模型配置")
        model_layout = QVBoxLayout()
        model_layout.addWidget(QLabel("模型:"))
        self.model_combo = QComboBox()
        self.model_combo.addItems(["PP-DocLayout-L", "PP-DocLayout-M", "PP-DocLayout-S"])
        self.model_combo.currentTextChanged.connect(self._on_model_changed)
        model_layout.addWidget(self.model_combo)
        
        model_layout.addWidget(QLabel("配置文件路径:"))
        self.config_path_input = QLineEdit()
        self.config_path_input.setPlaceholderText("paddlex/configs/modules/layout_detection/PP-DocLayout-L.yaml")
        # 设置默认值
        self.config_path_input.setText("paddlex/configs/modules/layout_detection/PP-DocLayout-L.yaml")
        model_layout.addWidget(self.config_path_input)
        model_group.setLayout(model_layout)
        layout.addWidget(model_group)
        
        # 评估参数
        eval_group = QGroupBox("评估参数")
        eval_layout = QVBoxLayout()
        
        device_layout = QHBoxLayout()
        device_layout.addWidget(QLabel("评估设备:"))
        self.device_combo = QComboBox()
        self.device_combo.addItems(["cpu", "gpu:0", "gpu:0,1"])
        # 设置默认设备
        if 'device' in self.default_config:
            index = self.device_combo.findText(self.default_config['device'])
            if index >= 0:
                self.device_combo.setCurrentIndex(index)
        device_layout.addWidget(self.device_combo)
        eval_layout.addLayout(device_layout)
        
        # 权重路径信息（只读显示）
        weight_layout = QHBoxLayout()
        weight_layout.addWidget(QLabel("权重路径:"))
        self.weight_path_label = QLabel("output_last/best_model/best_model.pdparams")
        self.weight_path_label.setStyleSheet("color: gray;")
        self.weight_path_label.setToolTip("评估使用的模型权重路径（相对路径）")
        weight_layout.addWidget(self.weight_path_label)
        eval_layout.addLayout(weight_layout)
        
        eval_group.setLayout(eval_layout)
        layout.addWidget(eval_group)
        
        # 按钮
        btn_layout = QHBoxLayout()
        ok_btn = QPushButton("开始评估")
        ok_btn.clicked.connect(self.accept)
        cancel_btn = QPushButton("取消")
        cancel_btn.clicked.connect(self.reject)
        btn_layout.addWidget(ok_btn)
        btn_layout.addWidget(cancel_btn)
        layout.addLayout(btn_layout)
    
    def _on_model_changed(self, model_name: str):
        """模型选择改变时更新配置文件路径"""
        config_path = f"paddlex/configs/modules/layout_detection/{model_name}.yaml"
        self.config_path_input.setText(config_path)
    
    def _update_dataset_path_display(self):
        """更新数据集路径显示"""
        # 获取当前数据集路径
        dataset_dir = self._get_current_dataset_dir()
        
        # 更新显示
        if hasattr(self, 'dataset_label'):
            self.dataset_label.setText(str(dataset_dir))
            self.dataset_label.setToolTip(str(dataset_dir))
    
    def _get_current_dataset_dir(self) -> Path:
        """获取当前数据集目录路径（评估配置对话框专用）"""
        # 尝试从父窗口获取 main_window 和插件实例
        main_window = None
        plugin_widget = None
        
        if hasattr(self.parent(), 'main_window'):
            main_window = self.parent().main_window
            plugin_widget = self.parent()
        elif hasattr(self.parent(), 'parent') and hasattr(self.parent().parent(), 'main_window'):
            main_window = self.parent().parent().main_window
            plugin_widget = self.parent().parent()
        
        # 如果找到了插件实例，优先从插件实例获取当前数据集路径
        if plugin_widget and hasattr(plugin_widget, '_current_dataset_dir'):
            return Path(plugin_widget._current_dataset_dir).resolve()
        
        # 如果找到了 main_window，尝试获取当前打开目录
        if main_window:
            # 优先从当前打开的图片路径获取数据集目录
            if hasattr(main_window, 'filename') and main_window.filename:
                image_path = Path(main_window.filename).resolve()
                # 如果图片在 images 子目录中，使用父目录作为数据集目录
                if image_path.parent.name == 'images':
                    dataset_dir = image_path.parent.parent
                    return dataset_dir.resolve()
                # 否则，使用图片所在目录作为数据集目录
                else:
                    dataset_dir = image_path.parent
                    return dataset_dir.resolve()
        
        # 如果没有找到数据集目录，使用默认路径
        plugin_file = Path(__file__).resolve()
        project_root = plugin_file.parent.parent.parent
        dataset_dir = project_root / "paddleocr" / "dataset"
        return dataset_dir.resolve()
    
    def get_config(self) -> Dict:
        """获取配置"""
        model = self.model_combo.currentText()
        config_path_input = self.config_path_input.text() or f"paddlex/configs/modules/layout_detection/{model}.yaml"
        
        # 获取项目根目录（用于构建工作目录）
        plugin_file = Path(__file__).resolve()
        project_root = plugin_file.parent.parent.parent
        
        # 获取数据集路径 - 使用 _get_current_dataset_dir 方法
        dataset_dir = self._get_current_dataset_dir()
        
        # 构建工作目录和输出目录 - 优先使用 seal_env/PaddleX，如果不存在则使用 seal_env_backup/PaddleX
        work_dir = project_root / "paddleocr" / "seal_env" / "PaddleX"
        if not work_dir.exists():
            work_dir = project_root / "paddleocr" / "seal_env_backup" / "PaddleX"
        
        output_dir = "output_last"  # 相对于 work_dir 的路径
        
        # 确保路径是绝对路径
        work_dir = work_dir.resolve()
        
        # 更新数据集路径显示（使用实际获取到的路径）
        if hasattr(self, 'dataset_label'):
            self.dataset_label.setText(str(dataset_dir))
            self.dataset_label.setToolTip(str(dataset_dir))
        
        # 验证配置文件是否存在（相对于 work_dir）
        print(f"\n[评估配置] 开始解析配置文件路径...")
        print(f"[评估配置] 项目根目录: {project_root}")
        print(f"[评估配置] 工作目录: {work_dir}")
        print(f"[评估配置] 工作目录是否存在: {work_dir.exists()}")
        print(f"[评估配置] 配置文件输入: {config_path_input}")
        
        config_path_abs = work_dir / config_path_input
        print(f"[评估配置] 尝试路径 1 (相对路径): {config_path_abs}")
        print(f"[评估配置] 路径 1 是否存在: {config_path_abs.exists()}")
        
        if not config_path_abs.exists():
            # 尝试在 seal_env_backup 中查找
            backup_work_dir = project_root / "paddleocr" / "seal_env_backup" / "PaddleX"
            backup_config_path = backup_work_dir / config_path_input
            print(f"[评估配置] 尝试路径 2 (备份路径): {backup_config_path}")
            print(f"[评估配置] 路径 2 是否存在: {backup_config_path.exists()}")
            
            if backup_config_path.exists():
                work_dir = backup_work_dir.resolve()
                config_path = config_path_input  # 使用相对路径
                print(f"[评估配置] ✓ 在 seal_env_backup 中找到配置文件: {backup_config_path}")
            else:
                # 如果都找不到，尝试作为绝对路径
                config_path_abs_alt = Path(config_path_input)
                print(f"[评估配置] 尝试路径 3 (绝对路径): {config_path_abs_alt}")
                print(f"[评估配置] 路径 3 是否存在: {config_path_abs_alt.exists()}")
                print(f"[评估配置] 路径 3 是否为绝对路径: {config_path_abs_alt.is_absolute()}")
                
                if config_path_abs_alt.exists() and config_path_abs_alt.is_absolute():
                    config_path = config_path_input  # 使用绝对路径
                    print(f"[评估配置] ✓ 使用绝对路径配置文件: {config_path_abs_alt}")
                else:
                    # 如果都找不到，使用输入路径（可能是用户自定义路径，稍后验证）
                    config_path = config_path_input
                    print(f"[评估配置] ⚠ 配置文件未找到，将在验证时检查: {config_path_input}")
                    print(f"[评估配置]   尝试的路径 1: {config_path_abs}")
                    print(f"[评估配置]   尝试的路径 2: {backup_config_path}")
                    print(f"[评估配置]   尝试的路径 3: {config_path_abs_alt}")
        else:
            config_path = config_path_input  # 使用相对路径（相对于 work_dir）
            print(f"[评估配置] ✓ 找到配置文件: {config_path_abs}")
        
        # 验证数据集目录和标注文件
        annotations_dir = dataset_dir / "annotations"
        val_file = annotations_dir / "instance_val.json"
        
        if not annotations_dir.exists():
            from PyQt5.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "警告",
                f"标注目录不存在: {annotations_dir}\n请确保数据集目录结构正确。"
            )
        elif not val_file.exists():
            from PyQt5.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "警告",
                f"未找到验证数据文件: {val_file}\n\n请确保已生成 instance_val.json 文件。"
            )
        
        return {
            "model": model,
            "config_path": config_path,  # 相对于 work_dir 的路径
            "dataset_dir": str(dataset_dir),
            "device": self.device_combo.currentText(),
            "output_dir": output_dir,
            "work_dir": str(work_dir),
            "main_py": "main.py",
        }


class TrainingPluginWidget(QWidget):
    """训练插件主界面"""
    
    def __init__(self, main_window):
        """
        初始化插件
        
        Args:
            main_window: Labelme 主窗口实例
        """
        super().__init__()
        self.main_window = main_window
        self.training_thread = None
        self.evaluation_thread = None
        self.inference_thread = None
        self.temp_files = []  # 记录临时文件，用于清理
        self.setup_ui()
        self.setup_zoom_shortcuts()  # 设置缩放快捷键
    
    def setup_ui(self):
        """设置 UI"""
        layout = QVBoxLayout()
        self.setLayout(layout)
        
        # 标题
        title = QLabel("训练管理")
        title_font = QFont()
        title_font.setBold(True)
        title_font.setPointSize(12)
        title.setFont(title_font)
        layout.addWidget(title)
        
        # 开始训练按钮
        self.train_btn = QPushButton("🚀 开始训练")
        self.train_btn.setToolTip("使用当前 Annotation 数据开始训练")
        self.train_btn.clicked.connect(self.start_training)
        layout.addWidget(self.train_btn)
        
        # 停止训练按钮
        self.stop_btn = QPushButton("⏹ 停止训练")
        self.stop_btn.setToolTip("停止当前训练任务")
        self.stop_btn.setEnabled(False)
        self.stop_btn.clicked.connect(self.stop_training)
        layout.addWidget(self.stop_btn)
        
        # 评估按钮
        self.evaluate_btn = QPushButton("📊 评估模型")
        self.evaluate_btn.setToolTip("使用验证集评估模型（instance_val.json）")
        self.evaluate_btn.clicked.connect(self.start_evaluation)
        layout.addWidget(self.evaluate_btn)
        
        # 文件推理按钮
        self.inference_btn = QPushButton("🔍 文件推理")
        self.inference_btn.setToolTip("加载单文件进行OCR识别和自动标注")
        self.inference_btn.clicked.connect(self.start_file_inference)
        layout.addWidget(self.inference_btn)
        
        # 训练状态
        status_group = QGroupBox("训练状态")
        status_layout = QVBoxLayout()
        
        self.status_label = QLabel("就绪")
        status_layout.addWidget(self.status_label)
        
        self.progress_bar = QProgressBar()
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setValue(0)
        status_layout.addWidget(self.progress_bar)
        
        status_group.setLayout(status_layout)
        layout.addWidget(status_group)
        
        # 训练日志
        log_group = QGroupBox("训练日志")
        log_layout = QVBoxLayout()
        
        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        self.log_text.setMaximumHeight(200)
        self.log_text.setMinimumHeight(150)
        self.log_text.setMaximumWidth(300)  # 限制最大宽度，防止日志框变宽
        self.log_text.setMinimumWidth(250)  # 设置最小宽度
        log_layout.addWidget(self.log_text)
        
        clear_btn = QPushButton("清空日志")
        clear_btn.clicked.connect(self.log_text.clear)
        log_layout.addWidget(clear_btn)
        
        log_group.setLayout(log_layout)
        layout.addWidget(log_group)
        
        # 版本管理
        version_group = QGroupBox("版本管理")
        version_layout = QVBoxLayout()
        
        # 版本管理按钮
        version_btn = QPushButton("📦 版本管理")
        version_btn.setToolTip("管理标注版本（Git）")
        version_btn.clicked.connect(self.show_version_manager)
        version_layout.addWidget(version_btn)
        
        # 创建版本按钮
        create_version_btn = QPushButton("➕ 创建版本")
        create_version_btn.setToolTip("创建新版本（提交当前标注）")
        create_version_btn.clicked.connect(self.create_version)
        version_layout.addWidget(create_version_btn)
        
        version_group.setLayout(version_layout)
        layout.addWidget(version_group)
        
        # 添加弹性空间
        layout.addStretch()
    
    def start_training(self):
        """开始训练"""
        # 检查是否有打开路径，如果没有则提示用户打开路径
        dataset_dir = self._get_dataset_dir()
        if dataset_dir is None:
            # 用户取消了打开路径，不进行训练
            return
        
        # 保存当前数据集路径到插件实例，供配置对话框使用
        self._current_dataset_dir = dataset_dir
        
        # 显示配置对话框，传入当前数据集路径
        dialog = TrainingConfigDialog(self)
        # 如果配置对话框需要，可以传入数据集路径
        if hasattr(dialog, '_get_current_dataset_dir'):
            # 确保配置对话框能获取到当前路径
            dialog._current_dataset_dir = dataset_dir
        
        if dialog.exec_() != QDialog.Accepted:
            return
        
        config = dialog.get_config()
        
        print("\n" + "="*80)
        print("[训练] 开始训练流程")
        print("="*80)
        print(f"[训练] 配置信息:")
        for key, value in config.items():
            print(f"  {key}: {value}")
        print("="*80 + "\n")
        
        # 验证配置
        print("[训练] 开始验证配置...")
        if not self._validate_config(config):
            print("[训练] ✗ 配置验证失败")
            return
        print("[训练] ✓ 配置验证通过")
        
        # 打印训练配置到命令行
        print("\n" + "="*80)
        print("🚀 开始训练")
        print("="*80)
        print(f"模型: {config['model']}")
        print(f"配置文件: {config['config_path']}")
        print(f"数据集目录: {config['dataset_dir']}")
        print(f"工作目录: {config['work_dir']}")
        print(f"输出目录: {config['output_dir']}")
        print(f"训练轮数: {config['epochs']}")
        print(f"训练设备: {config['device']}")
        print(f"主程序: {config['main_py']}")
        print("="*80 + "\n")
        
        # 创建训练线程
        self.training_thread = TrainingThread(config)
        self.training_thread.status_updated.connect(self.on_status_updated)
        self.training_thread.progress_updated.connect(self.on_progress_updated)
        self.training_thread.finished.connect(self.on_training_finished)
        
        # 启动训练
        self.training_thread.start()
        
        # 更新按钮状态
        self.train_btn.setEnabled(False)
        self.stop_btn.setEnabled(True)
        self.status_label.setText("训练中...")
        self.log_text.clear()
        self.log_text.append(f"开始训练 - 模型: {config['model']}")
        self.log_text.append(f"数据集: {config['dataset_dir']}")
        self.log_text.append(f"输出: {config['work_dir']}/{config['output_dir']}")
    
    def stop_training(self):
        """停止训练"""
        if self.training_thread and self.training_thread.isRunning():
            reply = QMessageBox.question(
                self,
                "确认",
                "确定要停止训练吗？",
                QMessageBox.Yes | QMessageBox.No
            )
            
            if reply == QMessageBox.Yes:
                self.training_thread.stop()
                self.status_label.setText("正在停止...")
                self.log_text.append("用户请求停止训练")
    
    def start_evaluation(self):
        """开始评估"""
        # 检查是否有打开路径，如果没有则提示用户打开路径
        dataset_dir = self._get_dataset_dir()
        if dataset_dir is None:
            # 用户取消了打开路径，不进行评估
            return
        
        # 检查验证文件是否存在
        annotations_dir = dataset_dir / 'annotations'
        val_file = annotations_dir / 'instance_val.json'
        
        if not val_file.exists():
            QMessageBox.warning(
                self,
                "警告",
                f"验证文件不存在: {val_file}\n\n"
                "请确保已生成 instance_val.json 文件。\n"
                "可以通过标注数据自动生成验证集。"
            )
            return
        
        # 保存当前数据集路径到插件实例，供配置对话框使用
        self._current_dataset_dir = dataset_dir
        
        # 获取评估配置（使用独立的评估配置对话框）
        dialog = EvaluationConfigDialog(self)
        if dialog.exec_() != QDialog.Accepted:
            return
        
        config = dialog.get_config()
        
        # 添加评估相关配置
        config['val_file'] = str(val_file)
        config['mode'] = 'evaluate'
        
        # 检查是否有训练好的模型权重
        work_dir = Path(config['work_dir'])
        output_dir = work_dir / config.get('output_dir', 'output_last')
        # 权重文件路径：output_last/best_model/best_model.pdparams
        weight_path = output_dir / 'best_model' / 'best_model.pdparams'
        
        if not weight_path.exists():
            # 尝试其他可能的权重文件名
            alt_weight_path = output_dir / 'best_model' / 'model.pdparams'
            if alt_weight_path.exists():
                weight_path = alt_weight_path
            else:
                reply = QMessageBox.question(
                    self,
                    "模型权重不存在",
                    f"未找到模型权重文件:\n- {weight_path}\n- {alt_weight_path}\n\n"
                    "是否继续评估（将使用配置文件中的默认权重路径）？",
                    QMessageBox.Yes | QMessageBox.No
                )
                if reply == QMessageBox.No:
                    return
                # 不设置 weight_path，让配置文件使用默认路径
                weight_path = None
        
        if weight_path and weight_path.exists():
            # 转换为相对路径（相对于 work_dir），与训练配置逻辑一致
            try:
                weight_path_relative = weight_path.relative_to(work_dir)
                config['weight_path'] = str(weight_path_relative)
                print(f"[评估] 使用权重文件（相对路径）: {weight_path_relative}")
            except ValueError:
                # 如果无法转换为相对路径，使用绝对路径
                config['weight_path'] = str(weight_path)
                print(f"[评估] 使用权重文件（绝对路径）: {weight_path}")
        
        print("\n" + "="*80)
        print("[评估] 开始评估流程")
        print("="*80)
        print(f"[评估] 配置信息:")
        for key, value in config.items():
            print(f"  {key}: {value}")
        print("="*80 + "\n")
        
        # 验证配置
        print("[评估] 开始验证配置...")
        if not self._validate_evaluation_config(config):
            print("[评估] ✗ 配置验证失败")
            return
        print("[评估] ✓ 配置验证通过")
        
        # 创建评估线程
        self.evaluation_thread = EvaluationThread(config)
        self.evaluation_thread.status_updated.connect(self.on_evaluation_status_updated)
        self.evaluation_thread.progress_updated.connect(self.on_evaluation_progress_updated)
        self.evaluation_thread.finished.connect(self.on_evaluation_finished)
        
        # 启动评估
        self.evaluation_thread.start()
        
        # 更新按钮状态
        self.evaluate_btn.setEnabled(False)
        self.status_label.setText("评估中...")
        self.log_text.clear()
        self.log_text.append(f"开始评估 - 模型: {config['model']}")
        self.log_text.append(f"数据集: {config['dataset_dir']}")
        self.log_text.append(f"验证文件: {config['val_file']}")
    
    def _validate_evaluation_config(self, config: Dict) -> bool:
        """验证评估配置"""
        print("\n[验证] 开始验证评估配置...")
        
        # 检查数据集目录
        dataset_dir = Path(config['dataset_dir'])
        if not dataset_dir.exists():
            QMessageBox.warning(
                self,
                "错误",
                f"数据集目录不存在: {dataset_dir}"
            )
            return False
        
        # 检查验证文件
        val_file = Path(config.get('val_file', ''))
        if not val_file.exists():
            QMessageBox.warning(
                self,
                "错误",
                f"验证文件不存在: {val_file}"
            )
            return False
        
        # 检查配置文件
        work_dir = Path(config['work_dir'])
        config_path = config['config_path']
        config_path_abs = work_dir / config_path if not Path(config_path).is_absolute() else Path(config_path)
        
        if not config_path_abs.exists():
            QMessageBox.warning(
                self,
                "错误",
                f"配置文件不存在: {config_path_abs}"
            )
            return False
        
        print("[验证] ✓ 评估配置验证通过")
        return True
    
    def on_evaluation_status_updated(self, status: str):
        """评估状态更新"""
        self.status_label.setText(status)
        self.log_text.append(status)
        # 自动滚动到底部
        self.log_text.verticalScrollBar().setValue(
            self.log_text.verticalScrollBar().maximum()
        )
        # 输出关键状态信息
        if any(keyword in status.lower() for keyword in ['error', 'warning', '完成', '失败', 'map', 'ap', 'accuracy']):
            print(f"[评估状态] {status}")
    
    def on_evaluation_progress_updated(self, progress: int):
        """评估进度更新"""
        self.progress_bar.setValue(progress)
    
    def start_file_inference(self):
        """开始文件推理"""
        # 打开文件选择对话框
        # 默认显示图片和PDF文件，方便用户选择
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "选择文件进行OCR识别",
            "",
            "图片和PDF文件 (*.jpg *.jpeg *.png *.bmp *.gif *.tif *.tiff *.pdf);;图片文件 (*.jpg *.jpeg *.png *.bmp *.gif *.tif *.tiff);;PDF文件 (*.pdf);;所有文件 (*.*)"
        )
        
        if not file_path:
            return
        
        file_path = Path(file_path)
        
        # 检查文件是否在当前数据集中，如果有标注则直接加载
        dataset_dir = self._get_dataset_dir()
        if dataset_dir:
            # 检查文件是否在数据集的 images 目录中
            images_dir = dataset_dir / 'images'
            if images_dir.exists():
                # 检查是否是同一个文件（通过文件名匹配）
                image_name = file_path.name
                dataset_image_path = images_dir / image_name
                
                # 如果文件在数据集中，检查是否有标注
                if dataset_image_path.exists() or file_path.parent == images_dir:
                    # 查找标注文件
                    annotations_dir = dataset_dir / 'annotations'
                    if annotations_dir.exists():
                        # 使用数据集中的图片路径（如果文件在images目录中）
                        check_image_path = dataset_image_path if dataset_image_path.exists() else file_path
                        
                        # 检查COCO格式标注
                        coco_files = [
                            annotations_dir / 'instance_train.json',
                            annotations_dir / 'instance_val.json',
                            annotations_dir / 'instance_all.json',
                        ]
                        
                        for coco_file in coco_files:
                            if coco_file.exists():
                                label_data = _load_coco_annotation_for_image(check_image_path, coco_file)
                                if label_data and label_data.get('shapes'):
                                    # 找到标注，直接加载
                                    self.log_text.append(f"文件已在数据集中，加载已有标注: {image_name}")
                                    self.status_label.setText("加载标注...")
                                    
                                    # 加载图片到canvas
                                    if self._load_image_to_canvas(file_path):
                                        # 加载标注到canvas
                                        self._load_annotations_to_canvas(label_data)
                                        self.status_label.setText("标注加载完成")
                                        self.log_text.append(f"✓ 已加载 {len(label_data.get('shapes', []))} 个标注")
                                        return
                                    else:
                                        QMessageBox.warning(
                                            self,
                                            "警告",
                                            "无法加载图片，但标注数据存在。"
                                        )
                                        return
                        
                        # 检查Labelme格式标注（单个JSON文件）
                        annotation_file = _find_annotation_file(check_image_path, annotations_dir)
                        if annotation_file and annotation_file.suffix == '.json':
                            try:
                                with open(annotation_file, 'r', encoding='utf-8') as f:
                                    label_data = json.load(f)
                                
                                if label_data.get('shapes'):
                                    # 找到标注，直接加载
                                    self.log_text.append(f"文件已在数据集中，加载已有标注: {image_name}")
                                    self.status_label.setText("加载标注...")
                                    
                                    # 加载图片到canvas
                                    if self._load_image_to_canvas(file_path):
                                        # 加载标注到canvas
                                        self._load_annotations_to_canvas(label_data)
                                        self.status_label.setText("标注加载完成")
                                        self.log_text.append(f"✓ 已加载 {len(label_data.get('shapes', []))} 个标注")
                                        return
                                    else:
                                        QMessageBox.warning(
                                            self,
                                            "警告",
                                            "无法加载图片，但标注数据存在。"
                                        )
                                        return
                            except Exception as e:
                                print(f"⚠ 读取标注文件失败: {e}")
        
        # 如果文件不在数据集中或没有标注，进行OCR识别
        self.log_text.append(f"文件不在数据集中或没有标注，开始OCR识别: {file_path.name}")
        
        # 如果是PDF，先转换为图片（在paddlelabel环境中）
        image_path = file_path
        if file_path.suffix.lower() == '.pdf':
            self.log_text.append("检测到PDF文件，先转换为图片...")
            self.status_label.setText("正在转换PDF为图片...")
            
            # 在paddlelabel环境中将PDF转换为图片
            image_path = self._convert_pdf_to_image_in_paddlelabel(file_path)
            if image_path is None:
                QMessageBox.critical(
                    self,
                    "错误",
                    "PDF转换为图片失败，请检查PyMuPDF是否已安装。"
                )
                self.status_label.setText("转换失败")
                return
            
            # 注意：不再记录为临时文件，因为已经保存到images目录
            # 文件已经直接保存到数据集的images目录中
            self.log_text.append(f"✓ PDF已转换为图片: {image_path.name}")
        
        # 使用seal_env环境执行推理脚本（对图片文件进行推理）
        self.log_text.append("使用seal_env环境执行推理脚本...")
        self.status_label.setText("正在识别（seal_env环境）...")
        
        # 使用seal_env环境执行推理（对图片文件进行推理）
        ocr_result = self._run_inference_in_seal_env(image_path)
        if ocr_result is None:
            self.status_label.setText("识别失败")
            return
        
        # 加载图片到canvas（直接加载转换后的图片）
        if not self._load_image_to_canvas(image_path):
            QMessageBox.warning(
                self,
                "警告",
                "无法加载图片到canvas，但识别结果已生成。"
            )
        
        # 将OCR结果转换为shapes并显示
        # 注意：对于多页PDF，只显示第一页的图片和标注结果
        self._display_ocr_results(ocr_result, image_path)
        
        # 询问是否保存到训练/评估集合
        # 如果用户选择保存，且是PDF多页，才需要拆分PDF为图片
        # 注意：图片已经保存在images目录中，这里主要是保存标注到对应的训练集/评估集
        # 传入已转换的image_path而不是原始file_path，避免重复保存
        saved = self._ask_save_to_dataset(image_path, ocr_result, original_file_path=file_path)
        
        # 注意：不再清理临时文件，因为文件已经直接保存到images目录
        # 如果用户选择不保存，文件仍然保留在images目录中（这是期望的行为）
        
        self.status_label.setText("识别完成")
    
    def setup_zoom_shortcuts(self):
        """设置缩放快捷键"""
        try:
            from PyQt5.QtWidgets import QShortcut
            from PyQt5.QtGui import QKeySequence
            from PyQt5.QtCore import Qt
            
            # 确保canvas可以接收键盘事件
            if hasattr(self.main_window, 'canvas'):
                canvas = self.main_window.canvas
                if hasattr(canvas, 'setFocusPolicy'):
                    canvas.setFocusPolicy(Qt.StrongFocus)
            
            # 放大快捷键: Ctrl + = 或 Ctrl + +
            zoom_in_shortcut = QShortcut(QKeySequence("Ctrl+="), self.main_window)
            zoom_in_shortcut.activated.connect(self.zoom_in)
            
            zoom_in_plus_shortcut = QShortcut(QKeySequence("Ctrl++"), self.main_window)
            zoom_in_plus_shortcut.activated.connect(self.zoom_in)
            
            # 缩小快捷键: Ctrl + -
            zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+-"), self.main_window)
            zoom_out_shortcut.activated.connect(self.zoom_out)
            
            # 重置缩放: Ctrl + 0
            zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self.main_window)
            zoom_reset_shortcut.activated.connect(self.zoom_reset)
            
            print("✓ 缩放快捷键已设置:")
            print("  - Ctrl + = 或 Ctrl + + : 放大")
            print("  - Ctrl + - : 缩小")
            print("  - Ctrl + 0 : 重置缩放")
        except Exception as e:
            print(f"⚠ 设置缩放快捷键失败: {e}")
    
    def zoom_in(self):
        """放大图片"""
        try:
            if hasattr(self.main_window, 'canvas'):
                canvas = self.main_window.canvas
                # 获取当前缩放比例
                current_scale = getattr(canvas, 'scale', 1.0)
                # 放大1.2倍，但限制最大缩放为10倍
                new_scale = min(current_scale * 1.2, 10.0)
                # 设置新的缩放比例
                if hasattr(canvas, 'scale'):
                    canvas.scale = new_scale
                # 刷新canvas（使用QTimer延迟刷新，避免在缩放过程中触发事件）
                from PyQt5.QtCore import QTimer
                QTimer.singleShot(10, lambda: (
                    canvas.update() if hasattr(canvas, 'update') else None,
                    canvas.repaint() if hasattr(canvas, 'repaint') else None
                ))
                print(f"✓ 放大到: {new_scale:.2f}x")
        except Exception as e:
            print(f"⚠ 放大失败: {e}")
            import traceback
            traceback.print_exc()
    
    def zoom_out(self):
        """缩小图片"""
        try:
            if hasattr(self.main_window, 'canvas'):
                canvas = self.main_window.canvas
                # 获取当前缩放比例
                current_scale = getattr(canvas, 'scale', 1.0)
                # 缩小到0.8倍
                new_scale = max(current_scale * 0.8, 0.1)  # 确保最小缩放为0.1
                # 设置新的缩放比例
                if hasattr(canvas, 'scale'):
                    canvas.scale = new_scale
                # 刷新canvas（使用QTimer延迟刷新，避免在缩放过程中触发事件）
                from PyQt5.QtCore import QTimer
                QTimer.singleShot(10, lambda: (
                    canvas.update() if hasattr(canvas, 'update') else None,
                    canvas.repaint() if hasattr(canvas, 'repaint') else None
                ))
                print(f"✓ 缩小到: {new_scale:.2f}x")
        except Exception as e:
            print(f"⚠ 缩小失败: {e}")
            import traceback
            traceback.print_exc()
    
    def zoom_reset(self):
        """重置缩放"""
        try:
            if hasattr(self.main_window, 'canvas'):
                canvas = self.main_window.canvas
                # 重置缩放为1.0
                if hasattr(canvas, 'scale'):
                    canvas.scale = 1.0
                # 调用adjustScale重新适应窗口
                if hasattr(self.main_window, 'adjustScale'):
                    self.main_window.adjustScale()
                elif hasattr(canvas, 'adjustScale'):
                    canvas.adjustScale()
                # 刷新canvas
                if hasattr(canvas, 'update'):
                    canvas.update()
                if hasattr(canvas, 'repaint'):
                    canvas.repaint()
                print("✓ 重置缩放到适应窗口")
        except Exception as e:
            print(f"⚠ 重置缩放失败: {e}")
    
    def _get_conda_path(self) -> str:
        """获取 conda 安装路径（使用 CondaUtils 工具类）"""
        if USE_NEW_UTILS and CondaUtils:
            try:
                return CondaUtils.get_conda_path()
            except Exception as e:
                print(f"⚠ 使用 CondaUtils 失败: {e}，使用默认路径")
        
        # 简单回退：返回默认路径
        return os.path.join(os.path.expanduser('~'), 'miniconda3')
    
    def _convert_pdf_to_image_in_paddlelabel(self, pdf_path: Path) -> Optional[Path]:
        """在paddlelabel环境中将PDF转换为图片（第一页）
        
        Args:
            pdf_path: PDF文件路径
        
        Returns:
            Path: 转换后的图片路径，失败返回None
        """
        try:
            # 获取数据集目录
            dataset_dir = self._get_dataset_dir()
            if dataset_dir is None:
                # 如果没有数据集目录，使用项目根目录下的 dataset
                current_dir = Path(__file__).parent.parent.parent
                project_root = current_dir
                dataset_dir = project_root / "paddleocr" / "dataset"
            
            # 创建images目录（与annotations平行）
            images_dir = dataset_dir / "images"
            images_dir.mkdir(parents=True, exist_ok=True)
            
            # 生成图片文件名（第一页）
            image_name = f"{pdf_path.stem}_page_1.png"
            image_path = images_dir / image_name
            
            # 同名文件直接覆盖
            
            # 获取conda路径
            conda_path = self._get_conda_path()
            
            # 创建临时Python脚本文件（避免转义问题）
            import tempfile
            with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f:
                script_path = Path(f.name)
                f.write(f'''import sys
import fitz  # PyMuPDF
from pathlib import Path

pdf_path = Path(r"{pdf_path}")
image_path = Path(r"{image_path}")

try:
    # 打开PDF
    doc = fitz.open(str(pdf_path))
    if len(doc) > 0:
        # 获取第一页
        page = doc[0]
        # 转换为图片（使用较高DPI，确保清晰度）
        # 使用matrix参数提高分辨率，zoom=2.0相当于约150 DPI
        zoom = 2.0
        mat = fitz.Matrix(zoom, zoom)
        pix = page.get_pixmap(matrix=mat)
        # 保存为PNG
        pix.save(str(image_path))
        doc.close()
        print(f"✓ PDF转换成功: {{image_path}} (尺寸: {{pix.width}}x{{pix.height}})")
        sys.exit(0)
    else:
        print("✗ PDF文件为空")
        sys.exit(1)
except Exception as e:
    print(f"✗ PDF转换失败: {{e}}")
    import traceback
    traceback.print_exc()
    sys.exit(1)
''')
            
            try:
                # 构建shell命令，在paddlelabel环境中执行
                shell_cmd = f"""
source {conda_path}/etc/profile.d/conda.sh && \
conda activate paddlelabel && \
python {script_path}
"""
                
                # 执行命令
                result = subprocess.run(
                    ['bash', '-c', shell_cmd.strip()],
                    capture_output=True,
                    text=True,
                    timeout=60  # 60秒超时
                )
            finally:
                # 清理临时脚本文件
                try:
                    script_path.unlink()
                except:
                    pass
            
            # 打印输出
            if result.stdout:
                print(result.stdout)
                self.log_text.append(result.stdout.strip())
            if result.stderr:
                print(result.stderr)
                self.log_text.append(result.stderr.strip())
            
            # 检查是否成功
            if result.returncode == 0 and image_path.exists():
                return image_path
            else:
                print(f"✗ PDF转换失败，退出码: {result.returncode}")
                return None
                
        except subprocess.TimeoutExpired:
            print("✗ PDF转换超时")
            self.log_text.append("✗ PDF转换超时")
            return None
        except Exception as e:
            print(f"✗ PDF转换异常: {e}")
            import traceback
            traceback.print_exc()
            self.log_text.append(f"✗ PDF转换异常: {e}")
            return None
    
    def _run_inference_in_seal_env(self, image_path: Path) -> Optional['OCRResult']:
        """在seal_env环境中执行推理脚本（对图片文件进行推理）
        
        Args:
            image_path: 要识别的图片文件路径（已从PDF转换）
        
        Returns:
            OCRResult: 推理结果，失败返回None
        """
        try:
            # 获取项目根目录
            current_dir = Path(__file__).parent.parent.parent
            project_root = current_dir
            
            # 获取工作目录和配置文件路径（与训练配置一致）
            work_dir = project_root / "paddleocr" / "seal_env" / "PaddleX"
            if not work_dir.exists():
                work_dir = project_root / "paddleocr" / "seal_env_backup" / "PaddleX"
            
            if not work_dir.exists():
                QMessageBox.warning(
                    self,
                    "错误",
                    f"无法找到PaddleX工作目录: {work_dir}\n\n"
                    "请确保PaddleX已正确安装。"
                )
                return None
            
            # 配置文件路径（默认使用PP-DocLayout-L.yaml）
            config_path = "paddlex/configs/modules/layout_detection/PP-DocLayout-L.yaml"
            
            # 创建推理线程（对图片文件进行推理）
            self.inference_thread = InferenceThread(
                file_path=image_path,  # 使用图片路径，而不是PDF路径
                work_dir=work_dir,
                config_path=config_path,
                device="cpu"  # 默认使用CPU，可以根据需要修改
            )
            
            # 连接信号
            self.inference_thread.status_updated.connect(
                lambda msg: (self.log_text.append(msg), self.status_label.setText(msg))
            )
            
            # 创建事件循环等待完成
            from PyQt5.QtCore import QEventLoop, QTimer
            loop = QEventLoop()
            ocr_result = [None]  # 使用列表以便在闭包中修改
            
            def on_finished(result_data, message):
                if result_data:
                    # 在主线程中转换结果（可以访问labelme环境中的模块）
                    # 对于图片文件，page_index应该是None或0（单页图片）
                    ocr_result[0] = self._convert_paddlex_result_to_ocr_result(result_data, target_page_index=0)
                self.log_text.append(message)
                loop.quit()
            
            self.inference_thread.finished.connect(on_finished)
            
            # 启动线程
            self.inference_thread.start()
            
            # 等待完成（最多等待5分钟）
            timer = QTimer()
            timer.setSingleShot(True)
            timer.timeout.connect(loop.quit)
            timer.start(300000)  # 5分钟超时
            
            loop.exec_()
            
            if ocr_result[0] is None:
                QMessageBox.warning(
                    self,
                    "推理失败",
                    "推理未完成或失败，请检查日志。"
                )
                return None
            
            return ocr_result[0]
            
        except Exception as e:
            print(f"⚠ 执行推理失败: {e}")
            import traceback
            traceback.print_exc()
            QMessageBox.critical(
                self,
                "错误",
                f"执行推理失败: {e}"
            )
            return None
    
    def _convert_paddlex_result_to_ocr_result(self, result_data: Dict, target_page_index: int = 0) -> Optional['OCRResult']:
        """将PaddleX推理结果转换为OCRResult格式
        
        Args:
            result_data: PaddleX推理结果JSON数据
            target_page_index: 目标页码（0表示第一页），只转换该页的结果。对于多页PDF，只显示第一页。
        
        Returns:
            OCRResult: 转换后的OCR结果
        """
        try:
            # 在主线程中导入（可以访问labelme环境中的模块）
            current_dir = Path(__file__).parent.parent.parent
            paddleocr_src = current_dir / "paddleocr" / "src"
            
            if paddleocr_src.exists():
                sys.path.insert(0, str(paddleocr_src))
            
            from service.ocr_service import OCRResult, OCRItem
            
            ocr_result = OCRResult()
            
            # PaddleX输出格式：{"res": {"input_path": "...", "page_index": 0, "boxes": [...]}}
            # 或者可能是列表格式（多页结果）：[{"res": {"page_index": 0, "boxes": [...]}}, ...]
            res_list = []
            
            # 处理不同的输出格式
            # 格式1: 单个结果 {"res": {"page_index": 0, "boxes": [...]}}
            # 格式2: 多个结果列表 [{"res": {...}}, {"res": {...}}]
            # 格式3: 多个结果（每行一个JSON，已在上层解析为列表）
            
            if isinstance(result_data, list):
                # 多页结果格式（列表）
                print(f"检测到列表格式，包含 {len(result_data)} 个结果，目标页: page_index={target_page_index}")
                for item in result_data:
                    if isinstance(item, dict):
                        # 可能是 {"res": {...}} 格式
                        if 'res' in item:
                            res = item['res']
                            page_index = res.get('page_index', None)
                            # 严格只处理第一页（page_index=0），忽略None（可能是单页图片）
                            if page_index == target_page_index:
                                res_list.append(res)
                                print(f"  ✓ 添加 page_index={page_index} 的结果，boxes数量: {len(res.get('boxes', []))}")
                            else:
                                print(f"  ✗ 跳过 page_index={page_index} 的结果（不是目标页）")
                        # 或者直接是 {"page_index": 0, "boxes": [...]} 格式
                        elif 'boxes' in item:
                            page_index = item.get('page_index', None)
                            if page_index == target_page_index:
                                res_list.append(item)
                                print(f"  ✓ 添加 page_index={page_index} 的结果，boxes数量: {len(item.get('boxes', []))}")
                            else:
                                print(f"  ✗ 跳过 page_index={page_index} 的结果（不是目标页）")
            elif isinstance(result_data, dict):
                # 单个结果格式
                if 'res' in result_data:
                    res = result_data.get('res', {})
                    page_index = res.get('page_index', None)
                    # 严格只处理第一页（page_index=0），如果page_index是None（单页图片），也接受
                    if page_index is None:
                        # 单页图片，没有page_index，接受
                        res_list.append(res)
                        print(f"✓ 添加单页结果（无page_index，视为第一页），boxes数量: {len(res.get('boxes', []))}")
                    elif page_index == target_page_index:
                        res_list.append(res)
                        print(f"✓ 添加单页结果，page_index={page_index}，boxes数量: {len(res.get('boxes', []))}")
                    else:
                        print(f"✗ 跳过单页结果，page_index={page_index}（不是目标页{target_page_index}）")
                # 或者直接是 {"page_index": 0, "boxes": [...]} 格式
                elif 'boxes' in result_data:
                    page_index = result_data.get('page_index', None)
                    if page_index is None:
                        # 单页图片，没有page_index，接受
                        res_list.append(result_data)
                        print(f"✓ 添加直接格式结果（无page_index，视为第一页），boxes数量: {len(result_data.get('boxes', []))}")
                    elif page_index == target_page_index:
                        res_list.append(result_data)
                        print(f"✓ 添加直接格式结果，page_index={page_index}，boxes数量: {len(result_data.get('boxes', []))}")
                    else:
                        print(f"✗ 跳过直接格式结果，page_index={page_index}（不是目标页{target_page_index}）")
            
            if not res_list:
                print(f"⚠ 警告: 没有找到 page_index={target_page_index} 的结果")
                # 如果没有找到目标页，尝试使用第一个结果（但会警告）
                if isinstance(result_data, list) and len(result_data) > 0:
                    first_item = result_data[0]
                    if isinstance(first_item, dict):
                        if 'res' in first_item:
                            res = first_item['res']
                            page_idx = res.get('page_index', 'N/A')
                            print(f"⚠ 警告: 使用第一个结果（page_index={page_idx}），但这不是目标页{target_page_index}！")
                            res_list.append(res)
                        elif 'boxes' in first_item:
                            page_idx = first_item.get('page_index', 'N/A')
                            print(f"⚠ 警告: 使用第一个结果（page_index={page_idx}），但这不是目标页{target_page_index}！")
                            res_list.append(first_item)
                elif isinstance(result_data, dict) and 'res' in result_data:
                    res = result_data['res']
                    page_idx = res.get('page_index', 'N/A')
                    if page_idx != target_page_index:
                        print(f"⚠ 警告: 使用的结果page_index={page_idx}，但这不是目标页{target_page_index}！")
                    res_list.append(res)
                if res_list:
                    print(f"⚠ 使用第一个可用结果，boxes数量: {len(res_list[0].get('boxes', []))}")
            
            # 类别映射（与训练时一致）
            category_map = {
                "Seal": "stamp",
                "Handwriting": "handwritten",
                "Text": "printed",
                "Title": "title",
                "Table": "table",
                "Figure": "figure",
            }
            
            # 处理所有匹配的结果
            total_boxes = 0
            for res in res_list:
                boxes = res.get('boxes', [])
                print(f"处理 {len(boxes)} 个检测框（page_index: {res.get('page_index', 'N/A')}）")
                
                for box in boxes:
                    label = box.get('label', '')
                    coordinate = box.get('coordinate', [])
                    score = box.get('score', 0.0)
                    
                    if len(coordinate) >= 4:
                        # 确定类别
                        category = category_map.get(label, "printed")
                        
                        # 创建OCRItem
                        item = OCRItem(
                            text="",  # PaddleX predict 不返回文本，只有坐标
                            coordinates=coordinate,
                            category=label,
                            confidence=score
                        )
                        
                        # 添加到对应的列表
                        if category == "stamp":
                            ocr_result.stamp.append(item)
                            total_boxes += 1
                        elif category == "handwritten":
                            ocr_result.handwritten.append(item)
                            total_boxes += 1
                        elif category == "printed":
                            ocr_result.printed.append(item)
                            total_boxes += 1
                        elif category == "title":
                            ocr_result.title.append(item)
                            total_boxes += 1
                        elif category == "table":
                            ocr_result.table.append(item)
                            total_boxes += 1
                        elif category == "figure":
                            ocr_result.figure.append(item)
                            total_boxes += 1
            
            print(f"✓ 转换完成，共 {total_boxes} 个检测框（印章: {len(ocr_result.stamp)}, 手写: {len(ocr_result.handwritten)}, 印刷: {len(ocr_result.printed)}）")
            return ocr_result
            
        except Exception as e:
            print(f"⚠ 转换推理结果失败: {e}")
            import traceback
            traceback.print_exc()
            return None
    
    def _process_pdf_if_needed(self, file_path: Path, dataset_dir: Optional[Path]) -> List[Path]:
        """处理PDF文件，如果是多页则拆分为多个图片（仅在保存时使用）
        
        Args:
            file_path: 文件路径
            dataset_dir: 数据集目录（用于创建tmp目录）
        
        Returns:
            List[Path]: 处理后的文件列表（如果是PDF多页，返回拆分后的图片列表；否则返回空列表）
        """
        # 如果不是PDF文件，直接返回
        if file_path.suffix.lower() != '.pdf':
            return []
        
        try:
            import fitz  # PyMuPDF
        except ImportError:
            QMessageBox.warning(
                self,
                "PDF处理需要PyMuPDF",
                "PDF文件拆分需要安装PyMuPDF库。\n\n"
                "请运行: pip install PyMuPDF"
            )
            return []
        
        try:
            # 打开PDF
            doc = fitz.open(str(file_path))
            page_count = len(doc)
            
            # 如果只有一页，不需要拆分
            if page_count <= 1:
                doc.close()
                return []
            
            # 多页PDF，需要拆分
            self.log_text.append(f"PDF文件有 {page_count} 页，开始拆分为图片...")
            
            # 获取数据集目录，确保images目录存在
            if not dataset_dir:
                dataset_dir = self._get_dataset_dir()
            if not dataset_dir:
                # 如果没有数据集目录，使用文件所在目录的父目录
                dataset_dir = file_path.parent.parent if file_path.parent.name == 'images' else file_path.parent
            
            # 创建images目录（与annotations平行）
            images_dir = dataset_dir / 'images'
            images_dir.mkdir(parents=True, exist_ok=True)
            
            # 拆分PDF为图片
            processed_files = []
            for page_num in range(page_count):
                page = doc[page_num]
                pix = page.get_pixmap()
                
                # 生成文件名：原文件名_页码.png
                stem = file_path.stem
                page_image_path = images_dir / f"{stem}_page_{page_num + 1}.png"
                
                # 同名文件直接覆盖
                
                # 保存图片
                pix.save(str(page_image_path))
                processed_files.append(page_image_path)
                
                self.log_text.append(f"✓ 已保存第 {page_num + 1} 页: {page_image_path.name}")
            
            doc.close()
            
            # 注意：不再记录为临时文件，因为已经保存到images目录
            # 如果用户不保存，这些文件会保留在images目录中（这是期望的行为）
            
            self.log_text.append(f"✓ PDF拆分完成，共 {len(processed_files)} 页")
            return processed_files
            
        except Exception as e:
            print(f"⚠ PDF拆分失败: {e}")
            import traceback
            traceback.print_exc()
            QMessageBox.warning(
                self,
                "PDF拆分失败",
                f"PDF拆分失败: {e}"
            )
            return []
    
    def _cleanup_temp_files(self, files: List[Path]):
        """清理临时文件
        
        Args:
            files: 要删除的文件列表
        """
        import shutil
        for file_path in files:
            try:
                if file_path.exists():
                    file_path.unlink()
                    print(f"✓ 已删除临时文件: {file_path}")
            except Exception as e:
                print(f"⚠ 删除临时文件失败 {file_path}: {e}")
        
        # 尝试删除tmp目录（如果为空）
        for file_path in files:
            tmp_dir = file_path.parent
            if tmp_dir.name == 'tmp' and tmp_dir.exists():
                try:
                    # 检查目录是否为空
                    if not any(tmp_dir.iterdir()):
                        tmp_dir.rmdir()
                        print(f"✓ 已删除临时目录: {tmp_dir}")
                except Exception as e:
                    print(f"⚠ 删除临时目录失败 {tmp_dir}: {e}")
                break  # 只尝试一次
    
    def _load_annotations_to_canvas(self, label_data: Dict):
        """将标注数据加载到canvas（使用labelme的标准方法）"""
        try:
            from labelme.app import Shape
            
            shapes = label_data.get('shapes', [])
            if not shapes:
                return
            
            # 获取图片尺寸（用于坐标缩放）
            image_width = label_data.get('imageWidth', 0)
            image_height = label_data.get('imageHeight', 0)
            
            # 如果label_data中没有尺寸，从canvas获取
            if image_width == 0 or image_height == 0:
                if hasattr(self.main_window, 'image') and self.main_window.image:
                    image_width = self.main_window.image.width()
                    image_height = self.main_window.image.height()
                elif hasattr(self.main_window, 'canvas') and hasattr(self.main_window.canvas, 'image'):
                    if self.main_window.canvas.image:
                        image_width = self.main_window.canvas.image.width()
                        image_height = self.main_window.canvas.image.height()
            
            # 转换标注数据为Shape对象
            canvas_shapes = []
            for shape_data in shapes:
                try:
                    points = shape_data.get('points', [])
                    if not points or len(points) < 2:
                        continue
                    
                    # 转换为QPointF列表
                    qpoints = []
                    for point in points:
                        if isinstance(point, (list, tuple)) and len(point) >= 2:
                            qpoints.append(QPointF(float(point[0]), float(point[1])))
                        elif hasattr(point, 'x') and hasattr(point, 'y'):
                            qpoints.append(QPointF(float(point.x()), float(point.y())))
                    
                    if len(qpoints) < 2:
                        continue
                    
                    # 创建Shape对象
                    shape = Shape(
                        label=shape_data.get('label', ''),
                        shape_type=shape_data.get('shape_type', 'rectangle'),
                        flags=shape_data.get('flags', {})
                    )
                    shape.points = qpoints
                    
                    if 'group_id' in shape_data:
                        shape.group_id = shape_data.get('group_id')
                    if 'description' in shape_data:
                        shape.description = shape_data.get('description', '')
                    
                    canvas_shapes.append(shape)
                except Exception as e:
                    print(f"⚠ 转换标注失败: {e}")
                    continue
            
            # 使用labelme的标准方法加载shapes
            if hasattr(self.main_window, 'canvas') and hasattr(self.main_window.canvas, 'loadShapes'):
                # 使用loadShapes方法（labelme的标准方法，会自动处理labelList）
                try:
                    self.main_window.canvas.loadShapes(canvas_shapes, replace=True)
                    print(f"✓ 已加载 {len(canvas_shapes)} 个标注到canvas（使用loadShapes）")
                    return
                except Exception as e:
                    print(f"⚠ loadShapes失败: {e}，使用备用方法")
            
            # 备用方法：手动设置
            if hasattr(self.main_window, 'canvas') and hasattr(self.main_window.canvas, 'shapes'):
                # 先清空labelList，避免关联错误
                if hasattr(self.main_window, 'labelList'):
                    try:
                        self.main_window.labelList.clear()
                    except:
                        pass
                
                # 设置shapes到canvas
                self.main_window.canvas.shapes = canvas_shapes
                
                # 更新labelList（必须在设置shapes之后）
                if hasattr(self.main_window, 'labelList'):
                    try:
                        # 清空后重新创建items
                        for shape in canvas_shapes:
                            try:
                                item = self.main_window.labelList.createItemFromShape(shape)
                                if item:
                                    self.main_window.labelList.addItem(item)
                            except Exception as e:
                                print(f"⚠ 添加标注到labelList失败: {e}")
                                continue
                    except Exception as e:
                        print(f"⚠ 更新labelList失败: {e}")
                
                # 刷新canvas
                if hasattr(self.main_window.canvas, 'update'):
                    self.main_window.canvas.update()
                
                print(f"✓ 已加载 {len(canvas_shapes)} 个标注到canvas")
            
        except Exception as e:
            print(f"⚠ 加载标注到canvas失败: {e}")
            import traceback
            traceback.print_exc()
    
    def _load_image_to_canvas(self, file_path: Path) -> bool:
        """加载图片到canvas（使用labelme的标准方法）"""
        try:
            # 如果是PDF文件，labelme的loadFile不支持，直接使用备用方法
            # 对于图片文件，先尝试使用labelme的标准loadFile方法
            if file_path.suffix.lower() != '.pdf':
                if hasattr(self.main_window, 'loadFile'):
                    try:
                        self.main_window.loadFile(str(file_path))
                        return True
                    except Exception as e:
                        print(f"⚠ 使用loadFile加载失败: {e}，尝试备用方法")
            
            # 备用方法：手动加载
            # 如果是PDF，需要先转换为图片
            if file_path.suffix.lower() == '.pdf':
                # 检查是否安装了PyMuPDF
                try:
                    import fitz  # PyMuPDF
                except ImportError as e:
                    error_msg = (
                        f"PDF文件需要安装PyMuPDF库才能打开。\n\n"
                        f"当前环境: {sys.executable}\n\n"
                        f"请运行: pip install PyMuPDF\n\n"
                        f"或者选择图片文件进行识别。\n\n"
                        f"错误详情: {e}"
                    )
                    QMessageBox.warning(
                        self,
                        "PDF文件需要PyMuPDF",
                        error_msg
                    )
                    print(f"⚠ PyMuPDF导入失败: {e}")
                    return False
                
                # 转换PDF第一页为图片
                try:
                    doc = fitz.open(str(file_path))
                    if len(doc) > 0:
                        page = doc[0]
                        # 使用默认DPI（72）保持PDF原始尺寸，不进行缩放
                        # 如果需要更高清晰度，可以调整matrix，但保持1:1比例
                        pix = page.get_pixmap()  # 使用默认DPI，保持原始尺寸
                        img_data = pix.tobytes("png")
                        
                        pixmap = QPixmap()
                        if not pixmap.loadFromData(img_data):
                            error_msg = (
                                f"PDF转换为图片失败。\n\n"
                                f"可能的原因：\n"
                                f"1. PDF文件损坏\n"
                                f"2. 图片数据格式问题\n"
                                f"3. PyQt5版本不兼容\n\n"
                                f"请检查PDF文件是否有效。"
                            )
                            QMessageBox.warning(self, "错误", error_msg)
                            doc.close()
                            return False
                        doc.close()
                    else:
                        QMessageBox.warning(self, "错误", "PDF文件为空")
                        return False
                except Exception as e:
                    error_msg = (
                        f"PDF转换失败: {e}\n\n"
                        f"文件路径: {file_path}\n\n"
                        f"请检查：\n"
                        f"1. PDF文件是否有效\n"
                        f"2. 文件是否有读取权限\n"
                        f"3. PyMuPDF版本是否兼容"
                    )
                    QMessageBox.warning(self, "错误", error_msg)
                    print(f"⚠ PDF转换异常: {e}")
                    import traceback
                    traceback.print_exc()
                    return False
            else:
                # 直接加载图片
                pixmap = QPixmap(str(file_path))
                if pixmap.isNull():
                    return False
            
            # 使用setImage方法（labelme的标准方法，会自动处理缩放）
            if hasattr(self.main_window, 'setImage'):
                try:
                    self.main_window.setImage(pixmap)
                    # 更新filename
                    if hasattr(self.main_window, 'filename'):
                        self.main_window.filename = str(file_path)
                    # 调整缩放，让图片适应窗口大小
                    if hasattr(self.main_window, 'canvas'):
                        # 调用adjustScale让图片适应窗口
                        if hasattr(self.main_window, 'adjustScale'):
                            self.main_window.adjustScale()
                        elif hasattr(self.main_window.canvas, 'adjustScale'):
                            self.main_window.canvas.adjustScale()
                        # 启用fitToWindow（如果存在），让图片适应窗口
                        if hasattr(self.main_window.canvas, 'fitToWindow'):
                            self.main_window.canvas.fitToWindow = True
                    return True
                except Exception as e:
                    print(f"⚠ setImage失败: {e}")
            
            # 最后的备用方法
            if hasattr(self.main_window, 'image'):
                self.main_window.image = pixmap
            
            if hasattr(self.main_window, 'canvas'):
                if hasattr(self.main_window.canvas, 'loadPixmap'):
                    self.main_window.canvas.loadPixmap(pixmap)
                elif hasattr(self.main_window.canvas, 'setPixmap'):
                    self.main_window.canvas.setPixmap(pixmap)
                elif hasattr(self.main_window.canvas, 'image'):
                    self.main_window.canvas.image = pixmap
                
                # 更新filename
                if hasattr(self.main_window, 'filename'):
                    self.main_window.filename = str(file_path)
                
                # 调整缩放，让图片适应窗口大小
                # 调用adjustScale让图片适应窗口
                if hasattr(self.main_window, 'adjustScale'):
                    self.main_window.adjustScale()
                elif hasattr(self.main_window.canvas, 'adjustScale'):
                    self.main_window.canvas.adjustScale()
                
                # 启用fitToWindow（如果存在），让图片适应窗口
                if hasattr(self.main_window.canvas, 'fitToWindow'):
                    self.main_window.canvas.fitToWindow = True
                
                # 刷新canvas
                if hasattr(self.main_window.canvas, 'update'):
                    self.main_window.canvas.update()
                if hasattr(self.main_window.canvas, 'repaint'):
                    self.main_window.canvas.repaint()
            
            return True
        except Exception as e:
            print(f"⚠ 加载图片失败: {e}")
            import traceback
            traceback.print_exc()
            return False
    
    def _display_ocr_results(self, ocr_result: 'OCRResult', file_path: Path):
        """将OCR结果转换为shapes并显示在canvas上"""
        try:
            from labelme.app import Shape
            
            # 获取图片尺寸（这是关键！必须获取实际显示的图片尺寸）
            img_width = None
            img_height = None
            
            # 方法1: 从main_window.image获取
            if hasattr(self.main_window, 'image') and self.main_window.image:
                img_width = self.main_window.image.width()
                img_height = self.main_window.image.height()
                print(f"从main_window.image获取尺寸: {img_width}x{img_height}")
            
            # 方法2: 从canvas.image获取
            if (img_width is None or img_height is None) and hasattr(self.main_window, 'canvas'):
                if hasattr(self.main_window.canvas, 'image') and self.main_window.canvas.image:
                    img_width = self.main_window.canvas.image.width()
                    img_height = self.main_window.canvas.image.height()
                    print(f"从canvas.image获取尺寸: {img_width}x{img_height}")
                elif hasattr(self.main_window.canvas, 'pixmap') and self.main_window.canvas.pixmap:
                    img_width = self.main_window.canvas.pixmap.width()
                    img_height = self.main_window.canvas.pixmap.height()
                    print(f"从canvas.pixmap获取尺寸: {img_width}x{img_height}")
            
            # 方法3: 如果是PDF，尝试从文件本身获取尺寸
            if (img_width is None or img_height is None) and file_path.suffix.lower() == '.pdf':
                try:
                    import fitz
                    doc = fitz.open(str(file_path))
                    if len(doc) > 0:
                        page = doc[0]
                        # 获取PDF页面的原始尺寸（点，1点=1/72英寸）
                        rect = page.rect
                        pdf_width = rect.width
                        pdf_height = rect.height
                        # 使用默认DPI 72转换为像素（PDF 1点 = 1像素 @ 72 DPI）
                        img_width = int(pdf_width)
                        img_height = int(pdf_height)
                        doc.close()
                        print(f"从PDF文件获取尺寸: {img_width}x{img_height} (PDF原始尺寸: {pdf_width}x{pdf_height})")
                except Exception as e:
                    print(f"⚠ 从PDF获取尺寸失败: {e}")
            
            # 如果还是无法获取，使用默认值并警告
            if img_width is None or img_height is None:
                print("⚠ 无法获取图片尺寸，尝试从文件读取")
                # 尝试从文件读取
                if file_path.exists():
                    try:
                        from PIL import Image
                        with Image.open(str(file_path)) as img:
                            img_width, img_height = img.size
                            print(f"从文件读取尺寸: {img_width}x{img_height}")
                    except Exception as e:
                        print(f"⚠ 从文件读取尺寸失败: {e}")
                        print("⚠ 使用默认尺寸，标注位置可能不准确")
                        img_width = 2000
                        img_height = 2000
            else:
                print(f"✓ 最终使用的图片尺寸: {img_width}x{img_height}")
            
            # 创建shapes列表
            shapes = []
            
            # 处理印章
            for item in ocr_result.stamp:
                shape = self._ocr_item_to_shape(item, "Seal", img_width, img_height)
                if shape:
                    shapes.append(shape)
            
            # 处理手写体
            for item in ocr_result.handwritten:
                shape = self._ocr_item_to_shape(item, "Handwriting", img_width, img_height)
                if shape:
                    shapes.append(shape)
            
            # 处理印刷文本（Text类别，这是最常见的类别）
            for item in ocr_result.printed:
                shape = self._ocr_item_to_shape(item, "Text", img_width, img_height)
                if shape:
                    shapes.append(shape)
            
            # 处理其他类别（可选，根据需求）
            for item in ocr_result.title:
                shape = self._ocr_item_to_shape(item, "Title", img_width, img_height)
                if shape:
                    shapes.append(shape)
            
            for item in ocr_result.table:
                shape = self._ocr_item_to_shape(item, "Table", img_width, img_height)
                if shape:
                    shapes.append(shape)
            
            for item in ocr_result.figure:
                shape = self._ocr_item_to_shape(item, "Figure", img_width, img_height)
                if shape:
                    shapes.append(shape)
            
            # 打印调试信息：显示所有不同的标签
            unique_labels = set()
            for shape in shapes:
                if hasattr(shape, 'label') and shape.label:
                    unique_labels.add(shape.label)
            print(f"[DEBUG] 准备加载 {len(shapes)} 个shapes，包含以下标签: {sorted(unique_labels)}")
            self.log_text.append(f"[DEBUG] 准备加载 {len(shapes)} 个shapes，标签: {', '.join(sorted(unique_labels))}")
            
            # 使用labelme的标准方法加载shapes
            # 优先使用 main_window.loadShapes，它会自动处理 labelList 和颜色分配
            if hasattr(self.main_window, 'loadShapes'):
                try:
                    self.main_window.loadShapes(shapes, replace=True)
                    
                    # 确保canvas显示标签
                    if hasattr(self.main_window.canvas, 'show_labels'):
                        self.main_window.canvas.show_labels = True
                    elif hasattr(self.main_window, 'showLabels'):
                        self.main_window.showLabels = True
                    
                    # 刷新canvas
                    if hasattr(self.main_window.canvas, 'update'):
                        self.main_window.canvas.update()
                    if hasattr(self.main_window.canvas, 'repaint'):
                        self.main_window.canvas.repaint()
                    
                    result_msg = f"✓ 已显示 {len(shapes)} 个OCR识别结果到canvas（使用main_window.loadShapes）"
                    print(result_msg)
                    self.log_text.append(result_msg)
                    if len(shapes) == 0:
                        print("⚠ 警告: 没有shapes被创建，请检查OCR结果和图片尺寸")
                        self.log_text.append("⚠ 警告: 没有shapes被创建")
                    return
                except Exception as e:
                    print(f"⚠ main_window.loadShapes失败: {e}，尝试canvas.loadShapes")
                    import traceback
                    traceback.print_exc()
            
            # 备用方法：使用 canvas.loadShapes
            if hasattr(self.main_window, 'canvas') and hasattr(self.main_window.canvas, 'loadShapes'):
                try:
                    self.main_window.canvas.loadShapes(shapes, replace=True)
                    
                    # 确保labelList和canvas.shapes正确同步
                    # 这是关键：需要确保每个shape在labelList中都有对应的item，并且关联正确
                    if hasattr(self.main_window, 'labelList') and hasattr(self.main_window.canvas, 'shapes'):
                        try:
                            # 先清空labelList，然后重新创建所有items
                            self.main_window.labelList.clear()
                            
                            # 为每个shape创建对应的labelList item
                            canvas_shapes = self.main_window.canvas.shapes
                            print(f"[DEBUG] canvas.shapes 中有 {len(canvas_shapes)} 个shapes")
                            created_items = 0
                            for shape in canvas_shapes:
                                if shape and hasattr(shape, 'label') and shape.label:
                                    try:
                                        # 使用createItemFromShape创建item（这是labelme的标准方法）
                                        if hasattr(self.main_window.labelList, 'createItemFromShape'):
                                            item = self.main_window.labelList.createItemFromShape(shape)
                                            if item:
                                                self.main_window.labelList.addItem(item)
                                                created_items += 1
                                        # 备用方法
                                        elif hasattr(self.main_window.labelList, 'addLabel'):
                                            self.main_window.labelList.addLabel(shape)
                                            created_items += 1
                                    except Exception as e:
                                        print(f"⚠ 为shape创建labelList item失败: {e}")
                                        import traceback
                                        traceback.print_exc()
                            print(f"[DEBUG] 在labelList中创建了 {created_items} 个items")
                            
                            # 验证labelList中的标签
                            if hasattr(self.main_window.labelList, 'count'):
                                label_list_labels = []
                                for i in range(self.main_window.labelList.count()):
                                    item = self.main_window.labelList.item(i)
                                    if item:
                                        label_list_labels.append(item.text())
                                print(f"[DEBUG] labelList中的标签: {label_list_labels}")
                        except Exception as e:
                            print(f"⚠ 同步labelList和canvas.shapes时出错: {e}")
                            import traceback
                            traceback.print_exc()
                    
                    # 确保canvas显示标签
                    if hasattr(self.main_window.canvas, 'show_labels'):
                        self.main_window.canvas.show_labels = True
                    elif hasattr(self.main_window, 'showLabels'):
                        self.main_window.showLabels = True
                    
                    # 刷新canvas
                    if hasattr(self.main_window.canvas, 'update'):
                        self.main_window.canvas.update()
                    if hasattr(self.main_window.canvas, 'repaint'):
                        self.main_window.canvas.repaint()
                    
                    result_msg = f"✓ 已显示 {len(shapes)} 个OCR识别结果到canvas（使用canvas.loadShapes）"
                    print(result_msg)
                    self.log_text.append(result_msg)
                    if len(shapes) == 0:
                        print("⚠ 警告: 没有shapes被创建，请检查OCR结果和图片尺寸")
                        self.log_text.append("⚠ 警告: 没有shapes被创建")
                    return
                except Exception as e:
                    print(f"⚠ canvas.loadShapes失败: {e}，使用备用方法")
                    import traceback
                    traceback.print_exc()
            
            # 备用方法：直接设置shapes
            if hasattr(self.main_window, 'canvas') and hasattr(self.main_window.canvas, 'shapes'):
                # 先清空labelList，避免关联错误
                if hasattr(self.main_window, 'labelList'):
                    try:
                        self.main_window.labelList.clear()
                    except:
                        pass
                
                # 设置shapes到canvas
                self.main_window.canvas.shapes = shapes
                
                # 更新labelList（必须在设置shapes之后）
                if hasattr(self.main_window, 'labelList'):
                    try:
                        # 使用labelList的标准方法为每个shape创建item
                        # 这样labelList和canvas.shapes才能正确关联
                        for shape in shapes:
                            if shape and hasattr(shape, 'label'):
                                try:
                                    # 方法1: 尝试使用createItemFromShape（这是labelme的标准方法）
                                    if hasattr(self.main_window.labelList, 'createItemFromShape'):
                                        item = self.main_window.labelList.createItemFromShape(shape)
                                        if item:
                                            self.main_window.labelList.addItem(item)
                                    # 方法2: 尝试使用addLabel方法（如果存在）
                                    elif hasattr(self.main_window.labelList, 'addLabel'):
                                        self.main_window.labelList.addLabel(shape)
                                    # 方法3: 直接创建QListWidgetItem（不推荐，但作为备用）
                                    else:
                                        from PyQt5.QtWidgets import QListWidgetItem
                                        item = QListWidgetItem(shape.label)
                                        # 尝试设置shape属性
                                        if hasattr(item, 'setShape'):
                                            item.setShape(shape)
                                        elif hasattr(item, 'shape'):
                                            item.shape = lambda: shape
                                        self.main_window.labelList.addItem(item)
                                except Exception as e:
                                    print(f"⚠ 添加标注到labelList失败: {e}")
                                    import traceback
                                    traceback.print_exc()
                                    continue
                    except Exception as e:
                        print(f"⚠ 更新labelList失败: {e}")
                        import traceback
                        traceback.print_exc()
                
                # 确保canvas显示标签（labelme的canvas默认会显示标签，但需要确保设置正确）
                # 检查并启用标签显示功能
                if hasattr(self.main_window.canvas, 'show_labels'):
                    self.main_window.canvas.show_labels = True
                elif hasattr(self.main_window, 'showLabels'):
                    self.main_window.showLabels = True
                
                # 刷新canvas以确保标签显示
                if hasattr(self.main_window.canvas, 'update'):
                    self.main_window.canvas.update()
                if hasattr(self.main_window.canvas, 'repaint'):
                    self.main_window.canvas.repaint()
            
            result_msg = f"✓ 已显示 {len(shapes)} 个OCR识别结果到canvas"
            print(result_msg)
            self.log_text.append(result_msg)
            if len(shapes) == 0:
                print("⚠ 警告: 没有shapes被创建，请检查OCR结果和图片尺寸")
                self.log_text.append("⚠ 警告: 没有shapes被创建，OCR结果可能为空或图片尺寸获取失败")
                # 打印OCR结果统计信息用于调试
                print(f"OCR结果统计: 印章={len(ocr_result.stamp)}, 手写={len(ocr_result.handwritten)}, 印刷={len(ocr_result.printed)}, 标题={len(ocr_result.title)}, 表格={len(ocr_result.table)}, 图片={len(ocr_result.figure)}")
                self.log_text.append(f"OCR结果统计: 印章={len(ocr_result.stamp)}, 手写={len(ocr_result.handwritten)}, 印刷={len(ocr_result.printed)}")
            
        except Exception as e:
            print(f"⚠ 显示OCR结果失败: {e}")
            import traceback
            traceback.print_exc()
            self.log_text.append(f"⚠ 显示OCR结果失败: {e}")
    
    def _ocr_item_to_shape(self, item: 'OCRItem', label: str, img_width: int, img_height: int):
        """将OCRItem转换为Labelme Shape
        
        Args:
            item: OCRItem对象，包含coordinates坐标
            label: 标签名称
            img_width: 当前canvas显示的图片宽度
            img_height: 当前canvas显示的图片高度
        
        注意：
            - PaddleX的坐标格式是 [x_min, y_min, x_max, y_max]，单位是像素
            - 如果PDF推理时使用的DPI与显示时不同，需要按比例缩放坐标
            - 但通常PaddleX推理和显示使用相同的图片，所以直接使用原始坐标
        """
        try:
            from labelme.app import Shape
            
            # OCR坐标格式: [x_min, y_min, x_max, y_max] (PaddleX格式)
            coords = item.coordinates
            if len(coords) < 4:
                print(f"⚠ 坐标数据不足: {len(coords)} 个值，需要4个")
                return None
            
            x_min, y_min, x_max, y_max = float(coords[0]), float(coords[1]), float(coords[2]), float(coords[3])
            
            # 打印调试信息
            print(f"  原始坐标: [{x_min:.2f}, {y_min:.2f}, {x_max:.2f}, {y_max:.2f}], 图片尺寸: {img_width}x{img_height}")
            
            # 确保坐标在图片范围内
            x_min = max(0, min(x_min, img_width))
            y_min = max(0, min(y_min, img_height))
            x_max = max(0, min(x_max, img_width))
            y_max = max(0, min(y_max, img_height))
            
            # 确保坐标顺序正确（左上角到右下角）
            if x_max <= x_min or y_max <= y_min:
                print(f"⚠ 坐标顺序错误: x_max({x_max}) <= x_min({x_min}) 或 y_max({y_max}) <= y_min({y_min})")
                return None
            
            # 转换为Labelme格式的points: [[x_min, y_min], [x_max, y_max]]
            # Labelme的矩形需要两个点：左上角和右下角
            points = [[float(x_min), float(y_min)], [float(x_max), float(y_max)]]
            
            print(f"  转换后坐标: 左上角({x_min:.2f}, {y_min:.2f}), 右下角({x_max:.2f}, {y_max:.2f})")
            
            # 创建Shape对象
            shape = Shape(
                label=label,
                shape_type='rectangle',
                flags={}
            )
            shape.points = [QPointF(p[0], p[1]) for p in points]
            
            # 添加文本描述
            if hasattr(shape, 'description'):
                shape.description = item.text
            
            return shape
        except Exception as e:
            print(f"⚠ 转换OCRItem失败: {e}")
            import traceback
            traceback.print_exc()
            return None
    
    def _ask_save_to_dataset(self, file_path: Path, ocr_result: 'OCRResult', original_file_path: Optional[Path] = None) -> bool:
        """询问是否保存到训练/评估集合
        
        Args:
            file_path: 要保存的文件路径（通常是已转换的图片路径）
            ocr_result: OCR识别结果
            original_file_path: 原始文件路径（如果是PDF，用于多页拆分）
        
        Returns:
            bool: 是否保存成功
        """
        # 创建消息框（使用模态对话框，不会影响主窗口大小）
        msg_box = QMessageBox(self)
        msg_box.setWindowTitle("保存标注")
        msg_box.setText("是否将识别结果保存到训练数据集？")
        msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
        msg_box.setModal(True)  # 设置为模态对话框
        
        # 设置按钮文本（兼容不同版本的PyQt5）
        try:
            # 方法1: 使用setButtonText（PyQt5 5.15+）
            if hasattr(msg_box, 'setButtonText'):
                msg_box.setButtonText(QMessageBox.Yes, "训练集")
                msg_box.setButtonText(QMessageBox.No, "评估集")
                msg_box.setButtonText(QMessageBox.Cancel, "不保存")
            else:
                # 方法2: 使用button方法获取按钮对象，然后设置文本
                yes_btn = msg_box.button(QMessageBox.Yes)
                no_btn = msg_box.button(QMessageBox.No)
                cancel_btn = msg_box.button(QMessageBox.Cancel)
                if yes_btn:
                    yes_btn.setText("训练集")
                if no_btn:
                    no_btn.setText("评估集")
                if cancel_btn:
                    cancel_btn.setText("不保存")
        except Exception as e:
            print(f"⚠ 设置按钮文本失败: {e}，使用默认文本")
        
        reply = msg_box.exec_()
        
        if reply == QMessageBox.Cancel:
            return False
        
        # 获取数据集目录
        dataset_dir = self._get_dataset_dir()
        if dataset_dir is None:
            QMessageBox.warning(
                self,
                "警告",
                "无法确定数据集目录，请先打开数据集目录。"
            )
            return
        
        # 确定保存到哪个集合
        is_train = (reply == QMessageBox.Yes)
        coco_file_name = "instance_train.json" if is_train else "instance_val.json"
        annotations_dir = dataset_dir / 'annotations'
        coco_file = annotations_dir / coco_file_name
        
        # 确保annotations目录存在
        annotations_dir.mkdir(parents=True, exist_ok=True)
        
        # 确保images目录存在
        images_dir = dataset_dir / 'images'
        images_dir.mkdir(parents=True, exist_ok=True)
        
        # 检查file_path是否已经在images目录中
        is_in_images_dir = str(file_path).startswith(str(images_dir))
        
        # 如果是PDF多页文件，需要拆分为图片保存
        # 否则直接保存原文件
        files_to_save = []
        
        # 如果file_path是PDF，且提供了original_file_path，使用original_file_path进行多页拆分
        pdf_path_to_check = original_file_path if original_file_path and original_file_path.suffix.lower() == '.pdf' else file_path
        
        if pdf_path_to_check.suffix.lower() == '.pdf' and not is_in_images_dir:
            # 检查PDF页数（使用原始PDF路径）
            try:
                import fitz  # PyMuPDF
                doc = fitz.open(str(pdf_path_to_check))
                page_count = len(doc)
                doc.close()
                
                if page_count > 1:
                    # 多页PDF，需要拆分
                    self.log_text.append(f"PDF文件有 {page_count} 页，开始拆分为图片...")
                    processed_files = self._process_pdf_if_needed(pdf_path_to_check, dataset_dir)
                    if processed_files:
                        files_to_save = processed_files
                    else:
                        # 拆分失败，使用已转换的图片（第一页）
                        files_to_save = [file_path]
                else:
                    # 单页PDF，使用已转换的图片
                    files_to_save = [file_path]
            except Exception as e:
                print(f"⚠ 检查PDF页数失败: {e}，使用已转换的图片")
                files_to_save = [file_path]
        else:
            # 非PDF文件，或文件已经在images目录中，直接使用
            files_to_save = [file_path]
        
        saved_count = 0
        saved_file_paths = []  # 记录实际保存的文件路径
        for save_file in files_to_save:
            # 处理图片文件（file_path已经是图片路径，不需要再转换PDF）
            image_name = save_file.name
            target_image_path = images_dir / image_name
            
            # 如果文件已经在images目录中，直接使用
            if is_in_images_dir and save_file.parent == images_dir:
                # 文件已经在images目录中，不需要移动或复制
                print(f"✓ 图片已在images目录中: {target_image_path}")
                saved_file_paths.append(target_image_path)
                saved_count += 1
            else:
                # 检查是否是同一个文件（避免复制到自己）
                try:
                    if save_file.resolve() == target_image_path.resolve():
                        # 是同一个文件，直接使用
                        print(f"✓ 图片路径相同，直接使用: {target_image_path}")
                        saved_file_paths.append(target_image_path)
                        saved_count += 1
                    else:
                        # 同名文件直接覆盖
                        import shutil
                        # 复制文件到images目录（覆盖同名文件）
                        shutil.copy2(save_file, target_image_path)
                        print(f"✓ 图片已保存到: {target_image_path} (已覆盖同名文件)")
                        saved_file_paths.append(target_image_path)
                        saved_count += 1
                except Exception as e:
                    QMessageBox.critical(
                        self,
                        "错误",
                        f"保存图片失败: {e}"
                    )
                    return False
        
        # 注意：不再处理tmp目录下的json文件，因为文件已经直接保存到images目录
        
        # 获取当前canvas的shapes（用户可能已经修改，仅用于第一页）
        shapes = []
        if hasattr(self.main_window, 'canvas') and hasattr(self.main_window.canvas, 'shapes'):
            shapes = self.main_window.canvas.shapes
        
        # 如果是多页PDF，需要对所有页面进行OCR识别并保存标注
        if len(saved_file_paths) > 1:
            # 多页PDF，对所有页面进行OCR识别
            self.log_text.append(f"检测到多页PDF，共 {len(saved_file_paths)} 页，开始对所有页面进行OCR识别...")
            self.status_label.setText(f"正在识别所有页面（{len(saved_file_paths)} 页）...")
            
            all_saved = True
            for idx, image_path in enumerate(saved_file_paths):
                page_num = idx + 1
                self.log_text.append(f"正在识别第 {page_num} 页: {image_path.name}")
                self.status_label.setText(f"正在识别第 {page_num}/{len(saved_file_paths)} 页...")
                
                # 对当前页面进行OCR识别
                page_ocr_result = self._run_inference_in_seal_env(image_path)
                if page_ocr_result is None:
                    self.log_text.append(f"⚠ 第 {page_num} 页OCR识别失败，跳过")
                    all_saved = False
                    continue
                
                # 保存当前页面的标注（不使用canvas的shapes，因为只有第一页有）
                page_shapes = shapes if idx == 0 else []  # 只有第一页使用canvas的shapes
                if not self._update_coco_file_with_ocr_result(
                    coco_file, image_path, page_shapes, page_ocr_result
                ):
                    self.log_text.append(f"⚠ 第 {page_num} 页标注保存失败")
                    all_saved = False
                else:
                    self.log_text.append(f"✓ 第 {page_num} 页标注已保存")
            
            if all_saved:
                QMessageBox.information(
                    self,
                    "成功",
                    f"所有页面标注已保存到{'训练集合' if is_train else '评估集合'}\n"
                    f"文件: {coco_file.name}\n"
                    f"已保存 {saved_count} 个文件，共 {len(saved_file_paths)} 页"
                )
                return True
            else:
                QMessageBox.warning(
                    self,
                    "部分成功",
                    f"部分页面标注保存失败，请检查日志。\n"
                    f"已保存 {saved_count} 个文件"
                )
                return False
        else:
            # 单页文件，使用原有的OCR结果
            if saved_file_paths:
                first_target_path = saved_file_paths[0]
            else:
                # 如果没有保存文件（理论上不应该发生），尝试从files_to_save推断
                if files_to_save[0].suffix.lower() == '.pdf':
                    # PDF已转换为PNG
                    stem = files_to_save[0].stem
                    first_target_path = images_dir / f"{stem}.png"
                    # 如果文件已存在（有后缀），需要找到正确的路径
                    if not first_target_path.exists():
                        # 查找实际保存的文件
                        for saved_file in images_dir.glob(f"{stem}*.png"):
                            first_target_path = saved_file
                            break
                else:
                    first_target_path = images_dir / files_to_save[0].name
                    # 如果文件已存在（有后缀），需要找到正确的路径
                    if not first_target_path.exists():
                        # 查找实际保存的文件
                        stem = files_to_save[0].stem
                        suffix = files_to_save[0].suffix
                        for saved_file in images_dir.glob(f"{stem}*{suffix}"):
                            first_target_path = saved_file
                            break
            
            if self._update_coco_file_with_ocr_result(
                coco_file, first_target_path, shapes, ocr_result
            ):
                QMessageBox.information(
                    self,
                    "成功",
                    f"标注已保存到{'训练集合' if is_train else '评估集合'}\n"
                    f"文件: {coco_file.name}\n"
                    f"已保存 {saved_count} 个文件"
                )
                return True
            else:
                QMessageBox.warning(
                    self,
                    "警告",
                    "保存标注失败，请检查日志。"
                )
                return False
    
    def _update_coco_file_with_ocr_result(
        self, coco_file: Path, image_path: Path, 
        shapes: List, ocr_result: 'OCRResult'
    ) -> bool:
        """使用OCR结果更新COCO文件"""
        try:
            # 获取图片尺寸
            from PIL import Image
            with Image.open(image_path) as img:
                image_width, image_height = img.size
            
            # 加载或创建COCO数据
            if coco_file.exists():
                with open(coco_file, 'r', encoding='utf-8') as f:
                    coco_data = json.load(f)
            else:
                coco_data = {
                    "info": {
                        "description": "COCO dataset",
                        "version": "1.0",
                        "year": datetime.now().year
                    },
                    "licenses": [],
                    "images": [],
                    "annotations": [],
                    "categories": []
                }
            
            # 确保类别存在（从标准类别列表添加缺失的类别）
            categories = {cat['name']: cat['id'] for cat in coco_data.get('categories', [])}
            next_category_id = max(categories.values(), default=0) + 1
            
            # 标准类别列表（完整的12个类别，包括 _background_）
            standard_categories = [
                "_background_", "Text", "Title", "Figure", "Figure caption", 
                "Table", "Table caption", "Header", "Footer", 
                "Seal", "Equation", "Handwriting"
            ]
            
            for category_name in standard_categories:
                if category_name not in categories:
                    categories[category_name] = next_category_id
                    coco_data['categories'].append({
                        "id": next_category_id,
                        "name": category_name,
                        "supercategory": ""
                    })
                    next_category_id += 1
            
            # 添加或更新图片信息（如果图片已存在，使用现有ID；否则创建新ID）
            image_id = None
            for img_info in coco_data.get('images', []):
                if img_info['file_name'] == image_path.name:
                    image_id = img_info['id']
                    # 更新图片尺寸（可能已改变）
                    img_info['width'] = image_width
                    img_info['height'] = image_height
                    break
            
            if image_id is None:
                # 创建新的图片ID（确保不与现有ID冲突）
                existing_image_ids = [img['id'] for img in coco_data.get('images', [])]
                image_id = max(existing_image_ids, default=0) + 1
                coco_data['images'].append({
                    "id": image_id,
                    "width": image_width,
                    "height": image_height,
                    "file_name": image_path.name
                })
            
            # 同名文件的标注进行叠加，不删除旧标注
            # 保留所有现有标注，只添加新标注
            
            # 添加新标注（优先使用canvas中的shapes，如果没有则使用OCR结果）
            # 确保annotation_id不与现有ID冲突（融合追加）
            existing_annotation_ids = [ann['id'] for ann in coco_data.get('annotations', [])]
            next_annotation_id = max(existing_annotation_ids, default=0) + 1
            
            if shapes:
                # 使用canvas中的shapes
                for shape in shapes:
                    if not hasattr(shape, 'label') or not hasattr(shape, 'points'):
                        continue
                    
                    label = shape.label
                    category_id = categories.get(label, 1)
                    
                    # 转换shape为COCO格式
                    annotation = self._convert_shape_to_coco_annotation(
                        shape, image_id, next_annotation_id, category_id, image_width, image_height
                    )
                    
                    if annotation:
                        coco_data['annotations'].append(annotation)
                        next_annotation_id += 1
            else:
                # 使用OCR结果
                for item in ocr_result.stamp:
                    annotation = self._ocr_item_to_coco_annotation(
                        item, image_id, next_annotation_id, categories['Seal'], image_width, image_height
                    )
                    if annotation:
                        coco_data['annotations'].append(annotation)
                        next_annotation_id += 1
                
                for item in ocr_result.handwritten:
                    annotation = self._ocr_item_to_coco_annotation(
                        item, image_id, next_annotation_id, categories['Handwriting'], image_width, image_height
                    )
                    if annotation:
                        coco_data['annotations'].append(annotation)
                        next_annotation_id += 1
            
            # 保存COCO文件
            import tempfile
            import shutil
            temp_file = coco_file.with_suffix('.tmp')
            
            with open(temp_file, 'w', encoding='utf-8') as f:
                json.dump(coco_data, f, ensure_ascii=False, indent=2)
                f.flush()
                os.fsync(f.fileno())
            
            shutil.move(str(temp_file), str(coco_file))
            
            print(f"✓ 已更新COCO文件: {coco_file.name} (包含 {len(coco_data.get('images', []))} 张图片, {len(coco_data.get('annotations', []))} 个标注)")
            return True
            
        except Exception as e:
            print(f"✗ 更新COCO文件失败: {e}")
            import traceback
            traceback.print_exc()
            return False
    
    def _ocr_item_to_coco_annotation(
        self, item: 'OCRItem', image_id: int, annotation_id: int,
        category_id: int, image_width: int, image_height: int
    ) -> Optional[dict]:
        """将OCRItem转换为COCO格式标注"""
        try:
            coords = item.coordinates
            if len(coords) < 4:
                return None
            
            x1, y1, x2, y2 = coords[0], coords[1], coords[2], coords[3]
            
            # 确保坐标在图片范围内
            x1 = max(0, min(x1, image_width))
            y1 = max(0, min(y1, image_height))
            x2 = max(0, min(x2, image_width))
            y2 = max(0, min(y2, image_height))
            
            # COCO格式: [x, y, width, height]
            x = min(x1, x2)
            y = min(y1, y2)
            w = abs(x2 - x1)
            h = abs(y2 - y1)
            
            if w <= 0 or h <= 0:
                return None
            
            # 从bbox生成顺时针的segmentation（矩形四个角点）
            # 顺时针顺序：左上 -> 右上 -> 右下 -> 左下
            segmentation = [[
                float(x),           # 左上 x
                float(y),           # 左上 y
                float(x + w),       # 右上 x
                float(y),           # 右上 y
                float(x + w),       # 右下 x
                float(y + h),       # 右下 y
                float(x),           # 左下 x
                float(y + h)        # 左下 y
            ]]
            
            return {
                "id": annotation_id,
                "image_id": image_id,
                "category_id": category_id,
                "bbox": [float(x), float(y), float(w), float(h)],
                "area": float(w * h),
                "iscrowd": 0,
                "segmentation": segmentation
            }
        except Exception as e:
            print(f"⚠ 转换OCRItem到COCO失败: {e}")
            return None
    
    def _convert_shape_to_coco_annotation(
        self, shape, image_id: int, annotation_id: int,
        category_id: int, image_width: int, image_height: int
    ) -> Optional[dict]:
        """将Labelme Shape转换为COCO格式标注"""
        try:
            # 获取points
            if hasattr(shape, 'points'):
                points = shape.points
            else:
                return None
            
            if not points or len(points) < 2:
                return None
            
            # 转换为坐标列表
            coords = []
            for point in points:
                if hasattr(point, 'x') and hasattr(point, 'y'):
                    coords.append([float(point.x()), float(point.y())])
                elif isinstance(point, (list, tuple)) and len(point) >= 2:
                    coords.append([float(point[0]), float(point[1])])
            
            if len(coords) < 2:
                return None
            
            # 获取shape_type
            shape_type = getattr(shape, 'shape_type', 'rectangle')
            
            # 转换为COCO格式
            if shape_type == 'rectangle':
                # 矩形框
                x_coords = [c[0] for c in coords]
                y_coords = [c[1] for c in coords]
                x = max(0, min(x_coords))
                y = max(0, min(y_coords))
                w = max(0, min(image_width, max(x_coords)) - x)
                h = max(0, min(image_height, max(y_coords)) - y)
                
                if w <= 0 or h <= 0:
                    return None
                
                # 从bbox生成顺时针的segmentation（矩形四个角点）
                # 顺时针顺序：左上 -> 右上 -> 右下 -> 左下
                segmentation = [[
                    float(x),           # 左上 x
                    float(y),           # 左上 y
                    float(x + w),       # 右上 x
                    float(y),           # 右上 y
                    float(x + w),       # 右下 x
                    float(y + h),       # 右下 y
                    float(x),           # 左下 x
                    float(y + h)        # 左下 y
                ]]
                
                return {
                    "id": annotation_id,
                    "image_id": image_id,
                    "category_id": category_id,
                    "bbox": [float(x), float(y), float(w), float(h)],
                    "area": float(w * h),
                    "iscrowd": 0,
                    "segmentation": segmentation
                }
            else:
                # 其他类型暂不支持
                return None
        except Exception as e:
            print(f"⚠ 转换Shape到COCO失败: {e}")
            return None
    
    def on_evaluation_finished(self, success: bool, message: str):
        """评估完成"""
        self.evaluate_btn.setEnabled(True)
        
        # 打印完成信息到命令行
        print("\n" + "="*80)
        if success:
            print(f"✓ {message}")
            self.status_label.setText("评估完成")
            self.progress_bar.setValue(100)
            QMessageBox.information(self, "成功", message)
        else:
            print(f"✗ {message}")
            self.status_label.setText("评估失败")
            QMessageBox.warning(self, "失败", message)
        print("="*80 + "\n")
        
        self.log_text.append(f"\n{'='*50}")
        self.log_text.append(f"评估{'完成' if success else '失败'}: {message}")
    
    def on_status_updated(self, status: str):
        """状态更新"""
        self.status_label.setText(status)
        self.log_text.append(status)
        # 自动滚动到底部
        self.log_text.verticalScrollBar().setValue(
            self.log_text.verticalScrollBar().maximum()
        )
        # 同时输出到命令行（避免重复，因为 TrainingThread.run() 已经输出了）
        # 这里只输出关键状态信息
        if any(keyword in status.lower() for keyword in ['error', 'warning', '完成', '失败', 'epoch', 'loss']):
            print(f"[训练状态] {status}")
    
    def on_progress_updated(self, progress: int):
        """进度更新"""
        self.progress_bar.setValue(progress)
    
    def on_training_finished(self, success: bool, message: str):
        """训练完成"""
        self.train_btn.setEnabled(True)
        self.stop_btn.setEnabled(False)
        
        # 打印完成信息到命令行
        print("\n" + "="*80)
        if success:
            print(f"✓ {message}")
            self.status_label.setText("训练完成")
            self.progress_bar.setValue(100)
            QMessageBox.information(self, "成功", message)
        else:
            print(f"✗ {message}")
            self.status_label.setText("训练失败")
            QMessageBox.warning(self, "失败", message)
        print("="*80 + "\n")
        
        self.log_text.append(f"\n{'='*50}")
        self.log_text.append(f"训练{'完成' if success else '失败'}: {message}")
    
    def _validate_config(self, config: Dict) -> bool:
        """验证配置"""
        print("\n[验证] 开始验证配置...")
        
        # 检查数据集目录
        dataset_dir = Path(config['dataset_dir'])
        print(f"[验证] 数据集目录: {dataset_dir}")
        if not dataset_dir.exists():
            print(f"[验证] ✗ 数据集目录不存在: {dataset_dir}")
            QMessageBox.warning(
                self,
                "错误",
                f"数据集目录不存在: {dataset_dir}"
            )
            return False
        print(f"[验证] ✓ 数据集目录存在")
        
        # 检查配置文件（config_path 是相对于 work_dir 的路径）
        work_dir = Path(config['work_dir'])
        config_path = config['config_path']
        
        print(f"[验证] 工作目录: {work_dir}")
        print(f"[验证] 配置文件路径（输入）: {config_path}")
        print(f"[验证] 工作目录是否存在: {work_dir.exists()}")
        
        # 先尝试作为相对路径（相对于 work_dir）
        config_path_abs = work_dir / config_path
        print(f"[验证] 尝试相对路径: {config_path_abs}")
        print(f"[验证] 相对路径是否存在: {config_path_abs.exists()}")
        
        if not config_path_abs.exists():
            # 如果相对路径不存在，尝试作为绝对路径
            config_path_abs = Path(config_path)
            print(f"[验证] 尝试绝对路径: {config_path_abs}")
            print(f"[验证] 绝对路径是否存在: {config_path_abs.exists()}")
            print(f"[验证] 绝对路径是否为绝对路径: {config_path_abs.is_absolute()}")
            
            if not config_path_abs.exists():
                # 尝试查找可能的路径
                print(f"[验证] 配置文件未找到，尝试查找可能的路径...")
                
                # 尝试在 seal_env_backup 中查找
                if 'seal_env' in str(work_dir):
                    backup_work_dir = work_dir.parent.parent / "seal_env_backup" / "PaddleX"
                    backup_config_path = backup_work_dir / config_path
                    print(f"[验证] 尝试备份路径: {backup_config_path}")
                    print(f"[验证] 备份路径是否存在: {backup_config_path.exists()}")
                    if backup_config_path.exists():
                        print(f"[验证] ✓ 在备份路径找到配置文件: {backup_config_path}")
                        return True
                
                # 尝试查找所有可能的 PaddleX 目录
                project_root = work_dir.parent.parent.parent.parent
                possible_dirs = [
                    project_root / "paddleocr" / "seal_env" / "PaddleX",
                    project_root / "paddleocr" / "seal_env_backup" / "PaddleX",
                ]
                for possible_dir in possible_dirs:
                    possible_config = possible_dir / config_path
                    print(f"[验证] 尝试路径: {possible_config}")
                    if possible_config.exists():
                        print(f"[验证] ✓ 找到配置文件: {possible_config}")
                        return True
                
                error_msg = f"配置文件不存在:\n"
                error_msg += f"  相对路径: {work_dir / config_path}\n"
                error_msg += f"  绝对路径: {config_path}\n"
                error_msg += f"\n工作目录: {work_dir}\n"
                error_msg += f"工作目录是否存在: {work_dir.exists()}\n"
                error_msg += f"\n请检查配置文件路径是否正确。"
                
                print(f"[验证] ✗ {error_msg}")
                QMessageBox.warning(
                    self,
                    "错误",
                    error_msg
                )
                return False
            else:
                print(f"[验证] ✓ 使用绝对路径配置文件: {config_path_abs}")
        else:
            print(f"[验证] ✓ 找到配置文件（相对路径）: {config_path_abs}")
        
        return True
    
    def show_version_manager(self):
        """显示版本管理对话框"""
        from version_manager import GitVersionManager
        
        # 获取数据集目录
        dataset_dir = self._get_dataset_dir()
        if not dataset_dir:
            QMessageBox.warning(
                self,
                "错误",
                "无法找到数据集目录（paddleocr/dataset）"
            )
            return
        
        manager = GitVersionManager(dataset_dir)
        
        # 如果未初始化，先初始化
        if not manager.is_git_initialized():
            reply = QMessageBox.question(
                self,
                "初始化 Git",
                "Git 仓库未初始化，是否现在初始化？",
                QMessageBox.Yes | QMessageBox.No
            )
            
            if reply == QMessageBox.Yes:
                success, message = manager.initialize_git()
                if not success:
                    QMessageBox.warning(self, "初始化失败", message)
                    return
                QMessageBox.information(self, "成功", message)
        
        # 创建版本管理对话框
        from version_manager_dialog import VersionManagerDialog
        dialog = VersionManagerDialog(self, manager)
        dialog.exec_()
    
    def create_version(self):
        """创建新版本（同步单个JSON到COCO文件）"""
        from version_manager import GitVersionManager
        from annotation_sync import sync_all_json_to_coco
        
        # 获取数据集目录
        dataset_dir = self._get_dataset_dir()
        if not dataset_dir:
            QMessageBox.warning(
                self,
                "错误",
                "无法找到数据集目录（paddleocr/dataset）"
            )
            return
        
        manager = GitVersionManager(dataset_dir)
        
        # 在创建版本前，同步所有单个JSON到COCO文件
        annotations_dir = dataset_dir / 'annotations'
        if annotations_dir.exists():
            success_count, fail_count = sync_all_json_to_coco(annotations_dir)
            if fail_count > 0:
                QMessageBox.warning(
                    self,
                    "警告",
                    f"同步完成：成功 {success_count} 个，失败 {fail_count} 个\n\n失败的JSON文件可能无法同步到COCO文件。"
                )
        
        # 输入版本描述
        description, ok = QMessageBox.getText(
            self,
            "创建版本",
            "请输入版本描述（可选）：",
            QLineEdit.Normal,
            f"Update: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
        )
        
        if not ok:
            return
        
        # 创建版本
        success, message, version_hash = manager.create_version(description)
        
        if success:
            QMessageBox.information(
                self,
                "成功",
                f"{message}\n版本ID: {version_hash}"
            )
        else:
            QMessageBox.warning(
                self,
                "失败",
                message
            )
    
    def _get_dataset_dir(self) -> Optional[Path]:
        """
        获取数据集目录
        优先使用当前打开的图片所在目录作为数据集目录
        如果没有打开路径，提示用户打开路径
        """
        # 优先从当前打开的图片路径获取数据集目录
        if hasattr(self.main_window, 'filename') and self.main_window.filename:
            image_path = Path(self.main_window.filename).resolve()
            # 如果图片在 images 子目录中，使用父目录作为数据集目录
            if image_path.parent.name == 'images':
                dataset_dir = image_path.parent.parent
                return dataset_dir.resolve()
            # 否则，使用图片所在目录作为数据集目录
            else:
                dataset_dir = image_path.parent
                return dataset_dir.resolve()
        
        # 如果没有打开路径，提示用户打开路径
        from PyQt5.QtWidgets import QMessageBox, QFileDialog
        reply = QMessageBox.question(
            self,
            "未打开路径",
            "当前没有打开任何文件或目录。\n\n"
            "请先打开一个图片文件或选择数据集目录。\n\n"
            "是否现在选择数据集目录？",
            QMessageBox.Yes | QMessageBox.No
        )
        
        if reply == QMessageBox.Yes:
            # 直接找到 paddleocr/dataset 作为默认路径
            plugin_file = Path(__file__).resolve()
            project_root = plugin_file.parent.parent.parent
            default_dataset_dir = project_root / 'paddleocr' / 'dataset'
            
            # 如果默认路径存在，直接使用它；否则让用户选择
            if default_dataset_dir.exists():
                # 检查并创建目录结构
                if self._check_and_create_dataset_structure(default_dataset_dir):
                    # 尝试打开 images 目录到 LABELME
                    images_dir = default_dataset_dir / 'images'
                    if images_dir.exists() and hasattr(self.main_window, 'openDir'):
                        try:
                            self.main_window.openDir(str(images_dir))
                            print(f"✓ 自动打开目录: {images_dir}")
                        except Exception as e:
                            print(f"⚠ 自动打开目录失败: {e}")
                    return default_dataset_dir.resolve()
                else:
                    # 用户取消了自动生成，返回 None
                    return None
            else:
                # 默认路径不存在，让用户选择，默认跳入 paddleocr/dataset
                # 即使目录不存在，也使用 default_dataset_dir 作为初始路径
                initial_dir = str(default_dataset_dir) if default_dataset_dir.parent.exists() else str(project_root)
                
                selected_dir = QFileDialog.getExistingDirectory(
                    self,
                    "选择数据集目录",
                    initial_dir,  # 默认跳入 paddleocr/dataset
                    QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
                )
                
                if selected_dir:
                    dataset_dir = Path(selected_dir)
                    # 如果选择的是 images 目录，使用父目录
                    if dataset_dir.name == 'images':
                        dataset_dir = dataset_dir.parent
                    # 检查并创建目录结构
                    if self._check_and_create_dataset_structure(dataset_dir):
                        return dataset_dir.resolve()
        
        return None
    
    def _check_and_create_dataset_structure(self, dataset_path: Path) -> bool:
        """
        检查并创建数据集目录结构
        如果没有 images 和 annotations 路径，则执行批量导入自动生成
        
        Args:
            dataset_path: 数据集根目录路径
            
        Returns:
            bool: 是否成功创建或已存在
        """
        images_dir = dataset_path / "images"
        annotations_dir = dataset_path / "annotations"
        
        # 如果 images 目录不存在，提示用户是否自动生成
        if not images_dir.exists():
            reply = QMessageBox.question(
                self,
                "目录不存在",
                f"数据集目录中未找到 images 子目录：\n{dataset_path}\n\n"
                "是否执行批量导入自动生成？\n"
                "（将扫描当前目录下的图片文件并创建 images 目录）",
                QMessageBox.Yes | QMessageBox.No
            )
            
            if reply == QMessageBox.Yes:
                # 执行批量导入自动生成
                return self._auto_generate_dataset_structure(dataset_path)
            else:
                return False
        
        # 如果 annotations 目录不存在，自动创建
        if not annotations_dir.exists():
            annotations_dir.mkdir(parents=True, exist_ok=True)
            print(f"✓ 自动创建 annotations 目录: {annotations_dir}")
        
        return True
    
    def _auto_generate_dataset_structure(self, dataset_path: Path) -> bool:
        """
        自动生成数据集目录结构
        
        扫描数据集目录下的图片文件，移动到 images 目录
        如果目录下没有图片，提示用户选择图片源目录
        
        Args:
            dataset_path: 数据集根目录路径
            
        Returns:
            bool: 是否成功生成
        """
        import shutil
        from PyQt5.QtWidgets import QFileDialog
        
        images_dir = dataset_path / "images"
        annotations_dir = dataset_path / "annotations"
        
        # 创建 images 目录
        images_dir.mkdir(parents=True, exist_ok=True)
        
        # 支持的图片格式
        image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tif', '.tiff'}
        
        # 扫描当前目录下的图片文件
        image_files = []
        for file in dataset_path.iterdir():
            if file.is_file() and file.suffix.lower() in image_extensions:
                image_files.append(file)
        
        # 如果当前目录没有图片，提示用户选择图片源目录
        if not image_files:
            source_dir = QFileDialog.getExistingDirectory(
                self,
                "选择图片源目录",
                str(dataset_path),
                QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
            )
            
            if not source_dir:
                return False
            
            source_path = Path(source_dir)
            for file in source_path.iterdir():
                if file.is_file() and file.suffix.lower() in image_extensions:
                    image_files.append(file)
        
        # 移动或复制图片到 images 目录
        moved_count = 0
        for image_file in image_files:
            try:
                dest_file = images_dir / image_file.name
                if not dest_file.exists():
                    # 如果图片不在 images 目录，移动它
                    if image_file.parent != images_dir:
                        shutil.move(str(image_file), str(dest_file))
                    else:
                        # 已经在 images 目录，跳过
                        pass
                    moved_count += 1
            except Exception as e:
                print(f"⚠ 移动图片失败 {image_file}: {e}")
        
        # 创建 annotations 目录
        annotations_dir.mkdir(parents=True, exist_ok=True)
        
        print(f"✓ 自动生成数据集结构完成：")
        print(f"  - images 目录: {images_dir}")
        print(f"  - annotations 目录: {annotations_dir}")
        print(f"  - 处理图片数量: {moved_count}")
        
        return True


def _load_coco_annotation_for_image(image_path: Path, coco_file: Path) -> Optional[Dict]:
    """
    从 COCO 格式文件中加载指定图片的标注
    
    Args:
        image_path: 图片文件路径
        coco_file: COCO 格式的 JSON 文件路径
        
    Returns:
        标注数据字典（Labelme 格式），如果不存在则返回 None
    """
    try:
        with open(coco_file, 'r', encoding='utf-8') as f:
            coco_data = json.load(f)
        
        # 获取图片文件名
        image_name = image_path.name
        
        # 规范化图片名称：去除路径，只保留文件名
        # COCO文件中的file_name可能是 "train_0045.jpg" 或 "annotations/train_0045.jpg" 或 "images/train_0045.jpg"
        # 统一处理为只比较文件名
        image_name_normalized = Path(image_name).name
        
        # 在 COCO 数据中查找对应的图片
        image_info = None
        for img in coco_data.get('images', []):
            coco_file_name = img.get('file_name', '')
            # 规范化COCO中的文件名（去除路径）
            coco_file_name_normalized = Path(coco_file_name).name
            
            # 精确匹配（比较规范化后的文件名）
            if coco_file_name_normalized == image_name_normalized:
                image_info = img
                break
            # 也尝试原始匹配（向后兼容）
            if coco_file_name == image_name or coco_file_name == image_name_normalized:
                image_info = img
                break
        
        if not image_info:
            return None
        
        image_id = image_info['id']
        
        # 查找该图片的所有标注
        annotations = [ann for ann in coco_data.get('annotations', []) 
                      if ann.get('image_id') == image_id]
        
        if not annotations:
            return None
        
        # 获取类别映射
        categories = {cat['id']: cat['name'] for cat in coco_data.get('categories', [])}
        
        # 转换为 Labelme 格式
        shapes = []
        for ann in annotations:
            # COCO 格式的 bbox: [x, y, width, height]
            bbox = ann.get('bbox', [])
            shape_type = 'rectangle'
            points = []
            
            if len(bbox) == 4:
                # 矩形框：Labelme 使用两个点（左上角和右下角）
                x, y, w, h = bbox
                points = [
                    [float(x), float(y)],           # 左上角
                    [float(x + w), float(y + h)]    # 右下角
                ]
            else:
                # 如果有 segmentation，使用 segmentation
                seg = ann.get('segmentation', [])
                if seg and len(seg) > 0:
                    shape_type = 'polygon'
                    # segmentation 可能是多边形点列表
                    if isinstance(seg[0], list):
                        # 格式: [[x1, y1, x2, y2, ...]]
                        seg_points = seg[0]
                        points = [[float(seg_points[i]), float(seg_points[i+1])] 
                                 for i in range(0, len(seg_points), 2)]
                    else:
                        # 格式: [x1, y1, x2, y2, ...]
                        points = [[float(seg[i]), float(seg[i+1])] 
                                 for i in range(0, len(seg), 2)]
                else:
                    continue
            
            # 确保 points 有效
            if not points or len(points) < 2:
                continue
            
            category_id = ann.get('category_id')
            label = categories.get(category_id, f'category_{category_id}')
            
            shape = {
                'label': label,
                'points': points,
                'group_id': ann.get('id'),
                'shape_type': shape_type,
                'flags': {},
                'description': ''
            }
            shapes.append(shape)
        
        # 构建 Labelme 格式数据
        # imagePath 需要相对于 JSON 文件的位置，指向 images 目录
        # JSON在 annotations/xxx.json，图片在 images/xxx.jpg
        # 所以 imagePath 应该是 ../images/xxx.jpg（相对于annotations目录）
        image_path_relative = f"../images/{Path(image_name).name}"
        
        labelme_data = {
            'version': '5.2.1',  # 使用当前版本号
            'flags': {},
            'shapes': shapes,
            'imagePath': image_path_relative,
            'imageWidth': image_info.get('width'),
            'imageHeight': image_info.get('height')
        }
        
        return labelme_data
        
    except Exception as e:
        print(f"⚠ 加载 COCO 标注失败 {coco_file}: {e}")
        import traceback
        traceback.print_exc()
        return None


def _find_annotation_file(image_path: Path, annotations_dir: Path) -> Optional[Path]:
    """
    在 annotations 目录中查找对应的标注文件
    
    支持两种格式：
    1. Labelme 格式：每个图片对应一个 JSON 文件（image.json）
    2. COCO 格式：所有标注在一个 JSON 文件中（instance_train.json, instance_val.json）
    
    Args:
        image_path: 图片文件路径
        annotations_dir: 标注文件目录
        
    Returns:
        标注文件路径，如果不存在则返回 None
    """
    if not annotations_dir.exists():
        return None
    
    # 获取图片文件名（不含扩展名）
    image_stem = image_path.stem
    image_name = image_path.name
    
    # 方法1: 尝试 Labelme 格式（单个 JSON 文件）
    possible_names = [
        f"{image_stem}.json",           # image.jpg -> image.json
        f"{image_path.name}.json",       # image.jpg -> image.jpg.json
    ]
    
    for name in possible_names:
        annotation_path = annotations_dir / name
        if annotation_path.exists():
            return annotation_path
    
    # 方法2: 尝试 COCO 格式（检查是否存在 COCO 文件）
    coco_files = [
        annotations_dir / 'instance_train.json',
        annotations_dir / 'instance_val.json',
        annotations_dir / 'instance_all.json',
        annotations_dir / 'annotations.json',
    ]
    
    for coco_file in coco_files:
        if coco_file.exists():
            # 检查该图片是否在 COCO 文件中
            try:
                with open(coco_file, 'r') as f:
                    coco_data = json.load(f)
                # 检查图片是否存在（规范化文件名匹配）
                image_name_normalized = Path(image_name).name
                for img in coco_data.get('images', []):
                    coco_file_name = img.get('file_name', '')
                    coco_file_name_normalized = Path(coco_file_name).name
                    if coco_file_name_normalized == image_name_normalized or coco_file_name == image_name:
                        # 返回一个特殊标记，表示这是 COCO 格式
                        return coco_file
            except:
                continue
    
    return None


def _create_label_list_item(label_text: str, label_list_widget=None):
    """
    创建 labelme LabelListWidgetItem 对象
    
    Args:
        label_text: 标签文本
        label_list_widget: labelme 的 labelList widget（可选，用于获取正确的 item 类型）
        
    Returns:
        LabelListWidgetItem 对象，如果失败返回 None
    """
    # 如果提供了 label_list_widget，尝试从它获取 item 类型
    if label_list_widget is not None:
        try:
            # 方法1: 检查 labelList 中已有的 item，使用相同类型创建新 item
            if hasattr(label_list_widget, 'count') and label_list_widget.count() > 0:
                existing_item = label_list_widget.item(0)
                if existing_item:
                    item_type = type(existing_item)
                    item_type_name = item_type.__name__
                    print(f"[DEBUG] 从已有 item 获取类型: {item_type_name}")
                    try:
                        # 使用相同类型创建新 item
                        new_item = item_type(label_text)
                        return new_item
                    except Exception as e:
                        print(f"⚠ 使用已有 item 类型创建失败: {e}")
                        # 尝试查看构造函数签名
                        import inspect
                        try:
                            sig = inspect.signature(item_type.__init__)
                            print(f"[DEBUG] {item_type_name}.__init__ 签名: {sig}")
                        except:
                            pass
            
            # 方法2: 检查 labelList 的类型
            widget_type = type(label_list_widget).__name__
            print(f"[DEBUG] labelList 类型: {widget_type}")
            
            # 方法3: 尝试从 labelList 的类获取 item 类型
            if hasattr(label_list_widget, 'itemClass'):
                LabelListWidgetItem = label_list_widget.itemClass
                try:
                    return LabelListWidgetItem(label_text)
                except Exception as e:
                    print(f"⚠ 使用 itemClass 创建失败: {e}")
            
            # 方法4: 尝试从 labelList 的模块获取
            widget_module = type(label_list_widget).__module__
            try:
                import importlib
                module = importlib.import_module(widget_module)
                LabelListWidgetItem = getattr(module, 'LabelListWidgetItem', None)
                if LabelListWidgetItem:
                    try:
                        return LabelListWidgetItem(label_text)
                    except Exception as e:
                        print(f"⚠ 从模块创建失败: {e}")
            except Exception as e:
                print(f"⚠ 导入模块失败: {e}")
        except Exception as e:
            print(f"⚠ 从 labelList 获取类型失败: {e}")
    
    # 尝试多种方式导入 LabelListWidgetItem
    LabelListWidgetItem = None
    
    # 方式1: 从 labelme.widgets 导入
    try:
        from labelme.widgets import LabelListWidgetItem
    except ImportError:
        # 方式2: 从 labelme.widgets.label_list_widget 导入
        try:
            from labelme.widgets.label_list_widget import LabelListWidgetItem
        except ImportError:
            # 方式3: 尝试从 labelme 的其他位置导入
            try:
                import labelme
                # 尝试查找 LabelListWidgetItem
                for attr_name in dir(labelme):
                    if 'LabelList' in attr_name or 'Item' in attr_name:
                        try:
                            obj = getattr(labelme, attr_name)
                            if 'Item' in str(type(obj).__name__):
                                LabelListWidgetItem = obj
                                break
                        except:
                            pass
            except:
                pass
    
    # 如果还是找不到，尝试使用 QListWidgetItem 并包装
    if LabelListWidgetItem is None:
        try:
            from PyQt5.QtWidgets import QListWidgetItem
            # 创建一个简单的包装类
            class LabelListWidgetItemWrapper(QListWidgetItem):
                def __init__(self, text):
                    super().__init__(text)
            
            LabelListWidgetItem = LabelListWidgetItemWrapper
        except:
            pass
    
    if LabelListWidgetItem:
        try:
            # 尝试不同的构造函数参数
            try:
                return LabelListWidgetItem(label_text)
            except TypeError:
                # 如果需要一个参数，尝试传递 None 或空列表
                try:
                    return LabelListWidgetItem()
                except:
                    return None
        except Exception as e:
            print(f"⚠ 创建 LabelListWidgetItem 失败: {e}")
            import traceback
            traceback.print_exc()
            return None
    
    return None


def _add_label_to_list(main_window, label_text: str) -> bool:
    """
    添加标签到 labelme 的 labelList
    
    Args:
        main_window: labelme 主窗口
        label_text: 标签文本
        
    Returns:
        是否成功添加
    """
    if not hasattr(main_window, 'labelList') or not main_window.labelList:
        return False
    
    if not label_text or not isinstance(label_text, str):
        return False
    
    # 检查是否已存在
    try:
        if hasattr(main_window.labelList, 'count'):
            for i in range(main_window.labelList.count()):
                item = main_window.labelList.item(i)
                if item and item.text() == label_text:
                    return True  # 已存在，返回成功
    except:
        pass
    
    # 尝试添加
    try:
        # 传递 labelList 以便获取正确的 item 类型
        item = _create_label_list_item(label_text, main_window.labelList)
        if item:
            main_window.labelList.addItem(item)
            return True
        else:
            # 如果创建对象失败，尝试直接添加字符串（某些版本可能支持）
            try:
                main_window.labelList.addItem(label_text)
                return True
            except Exception as e2:
                print(f"⚠ 直接添加字符串也失败: {e2}")
                # 最后尝试：使用 labelme 的 loadLabels 方法（如果存在）
                if hasattr(main_window, 'loadLabels'):
                    try:
                        # loadLabels 可能需要特定格式
                        main_window.loadLabels([label_text])
                        return True
                    except Exception as e3:
                        print(f"⚠ loadLabels 方法也失败: {e3}")
                return False
    except Exception as e:
        print(f"⚠ 添加标签 '{label_text}' 失败: {e}")
        import traceback
        traceback.print_exc()
        return False


def _get_annotations_dir(image_path: Path) -> Optional[Path]:
    """
    根据图片路径查找对应的 annotations 目录
    
    支持多种目录结构：
    - dataset/images 和 dataset/annotations
    - images 和 annotations（同级目录）
    
    注意：排除 tmp/annotations 目录，应该使用 dataset/annotations
    
    Args:
        image_path: 图片文件路径
        
    Returns:
        annotations 目录路径，如果不存在则返回 None
    """
    image_dir = image_path.parent
    
    # 排除 tmp 目录下的 annotations（应该使用 dataset/annotations）
    if 'tmp' in str(image_path).lower():
        # 如果图片在 tmp 目录，向上查找 dataset/annotations
        current = image_path.parent
        while current != current.parent:  # 直到根目录
            if current.name == 'tmp':
                # 找到 tmp 目录，使用其父目录的 annotations
                dataset_dir = current.parent
                annotations_dir = dataset_dir / 'annotations'
                if annotations_dir.exists() and annotations_dir.is_dir():
                    return annotations_dir
            current = current.parent
    
    # 方法1: 检查父目录是否有 annotations 子目录
    # 结构: dataset/images/xxx.jpg -> dataset/annotations/
    dataset_dir = image_dir.parent
    annotations_dir = dataset_dir / 'annotations'
    # 排除 tmp/annotations
    if annotations_dir.exists() and annotations_dir.is_dir() and 'tmp' not in str(annotations_dir):
        return annotations_dir
    
    # 方法2: 检查同级目录是否有 annotations
    # 结构: images/xxx.jpg -> annotations/
    annotations_dir = image_dir.parent / 'annotations'
    # 排除 tmp/annotations
    if annotations_dir.exists() and annotations_dir.is_dir() and 'tmp' not in str(annotations_dir):
        return annotations_dir
    
    # 方法3: 检查当前目录是否有 annotations
    # 结构: xxx.jpg -> annotations/
    annotations_dir = image_dir / 'annotations'
    # 排除 tmp/annotations
    if annotations_dir.exists() and annotations_dir.is_dir() and 'tmp' not in str(annotations_dir):
        return annotations_dir
    
    return None


def _hook_load_file(main_window):
    """
    拦截 Labelme 的 loadFile 方法，支持从分离的 annotations 目录加载标注
    
    Args:
        main_window: Labelme 主窗口实例
    """
    # 保存原始的 loadFile 方法
    original_load_file = main_window.loadFile
    
    def new_load_file(filename=None):
        """
        增强的 loadFile 方法，支持从 annotations 目录加载标注
        """
        # 优先使用原始方法（让 labelme 正常加载图片和标注）
        result = None
        image_load_failed = False
        
        if filename:
            try:
                result = original_load_file(filename)
                # 如果原始方法成功，继续执行（后续还会加载 COCO 标注）
            except Exception as e:
                # 如果原始方法失败（通常是 PIL load() 问题），使用 Qt 加载图片
                error_msg = str(e)
                if 'broken data stream' in error_msg.lower() or 'pil' in error_msg.lower() or 'image' in error_msg.lower() or 'zero' in error_msg.lower():
                    print(f"⚠ Labelme原始加载失败（PIL load()问题）: {error_msg}")
                    print(f"   文件: {filename}")
                    print(f"   说明: 文件格式正确，但Labelme的PIL无法load()")
                    print(f"   处理: 使用Qt加载图片，绕过PIL问题")
                    print(f"   状态: 图片将正常显示，不影响标注功能")
                    
                    # 使用Qt加载图片（不依赖PIL的load()）
                    if os.path.exists(filename):
                        try:
                            from PyQt5.QtGui import QPixmap, QImage
                            pixmap = QPixmap(filename)
                            if not pixmap.isNull():
                                # 检查图片尺寸是否有效（避免除零错误）
                                if pixmap.width() > 0 and pixmap.height() > 0:
                                    # 设置当前文件路径（必须在设置图片之前）
                                    if hasattr(main_window, 'filename'):
                                        main_window.filename = filename
                                    if hasattr(main_window, 'imagePath'):
                                        main_window.imagePath = filename
                                    if hasattr(main_window, 'currentFile'):
                                        main_window.currentFile = filename
                                    
                                    # 优先使用 setImage 方法（labelme 的标准方法，会自动处理 canvas 和所有状态）
                                    setImage_success = False
                                    if hasattr(main_window, 'setImage'):
                                        try:
                                            main_window.setImage(pixmap)
                                            print(f"✓ 使用 setImage 方法加载图片: {filename}")
                                            setImage_success = True
                                            result = True
                                        except Exception as e:
                                            print(f"⚠ setImage 方法失败: {e}, 尝试直接设置 canvas")
                                            setImage_success = False
                                    
                                    # 如果 setImage 失败或不存在，直接设置 canvas
                                    if not setImage_success:
                                        # 设置 main_window.image
                                        if hasattr(main_window, 'image'):
                                            main_window.image = pixmap
                                        
                                        # 直接设置 canvas 的图片（确保显示）
                                        if hasattr(main_window, 'canvas'):
                                            try:
                                                # 尝试多种方式设置 canvas 图片
                                                if hasattr(main_window.canvas, 'loadPixmap'):
                                                    main_window.canvas.loadPixmap(pixmap)
                                                    print(f"✓ 使用 loadPixmap 设置 canvas: {filename}")
                                                elif hasattr(main_window.canvas, 'setPixmap'):
                                                    main_window.canvas.setPixmap(pixmap)
                                                    print(f"✓ 使用 setPixmap 设置 canvas: {filename}")
                                                elif hasattr(main_window.canvas, 'image'):
                                                    main_window.canvas.image = pixmap
                                                    print(f"✓ 直接设置 canvas.image: {filename}")
                                                else:
                                                    print(f"⚠ canvas 没有找到设置图片的方法")
                                                
                                                result = True
                                            except Exception as e:
                                                print(f"⚠ 设置 canvas 图片时出错: {e}")
                                                import traceback
                                                traceback.print_exc()
                                    
                                    # 无论 setImage 是否成功，都需要确保 canvas 正确初始化
                                    if result and hasattr(main_window, 'canvas'):
                                        try:
                                            # 调整缩放（labelme 需要这个来正确显示和交互）
                                            if hasattr(main_window, 'adjustScale'):
                                                main_window.adjustScale()
                                            elif hasattr(main_window.canvas, 'adjustScale'):
                                                main_window.canvas.adjustScale()
                                            
                                            # 确保 canvas 启用编辑
                                            if hasattr(main_window.canvas, 'setEnabled'):
                                                main_window.canvas.setEnabled(True)
                                            
                                            # 更新 canvas 显示
                                            main_window.canvas.update()
                                            if hasattr(main_window.canvas, 'repaint'):
                                                main_window.canvas.repaint()
                                        except Exception as e:
                                            print(f"⚠ 设置 canvas 时出错: {e}")
                                    
                                    # 确保 labelFile 存在（labelme 需要这个来管理状态和启用编辑）
                                    # 如果 labelFile 不存在，labelme 可能认为没有文件被加载，从而禁用编辑功能
                                    if result and (not hasattr(main_window, 'labelFile') or main_window.labelFile is None):
                                        try:
                                            # 尝试创建或加载 labelFile
                                            # 查找对应的 JSON 文件
                                            json_file = str(Path(filename).with_suffix('.json'))
                                            if os.path.exists(json_file):
                                                # 如果 JSON 文件存在，尝试加载它
                                                try:
                                                    from labelme.label_file import LabelFile
                                                    main_window.labelFile = LabelFile(json_file)
                                                    print(f"✓ 加载对应的 JSON 文件: {json_file}")
                                                except Exception as e:
                                                    print(f"⚠ 加载 JSON 文件失败: {e}, 创建空的 labelFile")
                                                    # 如果加载失败，创建一个空的 labelFile
                                                    main_window.labelFile = None
                                            else:
                                                # 如果 JSON 文件不存在，设置为 None（labelme 会在保存时创建）
                                                main_window.labelFile = None
                                        except Exception as e:
                                            print(f"⚠ 初始化 labelFile 时出错: {e}")
                                            main_window.labelFile = None
                                    
                                    # 确保 canvas 可以交互（关键！）
                                    if hasattr(main_window, 'canvas'):
                                        try:
                                            # 确保 canvas 启用
                                            if hasattr(main_window.canvas, 'setEnabled'):
                                                main_window.canvas.setEnabled(True)
                                            # 确保 canvas 可以接收鼠标事件
                                            if hasattr(main_window.canvas, 'setMouseTracking'):
                                                main_window.canvas.setMouseTracking(True)
                                            # 确保 canvas 可以接收键盘事件
                                            if hasattr(main_window.canvas, 'setFocusPolicy'):
                                                from PyQt5.QtCore import Qt
                                                main_window.canvas.setFocusPolicy(Qt.StrongFocus)
                                        except Exception as e:
                                            print(f"⚠ 设置 canvas 交互时出错: {e}")
                                    
                                    # 更新窗口标题（如果labelme有这个功能）
                                    if hasattr(main_window, 'updateWindowTitle'):
                                        try:
                                            main_window.updateWindowTitle()
                                        except:
                                            pass
                                    
                                    # 强制刷新界面
                                    try:
                                        if hasattr(main_window, 'update'):
                                            main_window.update()
                                        if hasattr(main_window, 'repaint'):
                                            main_window.repaint()
                                    except:
                                        pass
                                    
                                    if result:
                                        print(f"✓ 使用Qt成功加载图片: {filename}")
                                else:
                                    image_load_failed = True
                                    print(f"⚠ Qt加载的图片尺寸无效（{pixmap.width()}x{pixmap.height()}）: {filename}")
                            else:
                                image_load_failed = True
                                print(f"⚠ Qt无法加载图片: {filename}")
                        except Exception as e2:
                            image_load_failed = True
                            print(f"⚠ Qt图片加载失败: {e2}")
                    else:
                        image_load_failed = True
                else:
                    # 其他错误，重新抛出（让 labelme 处理）
                    raise
            except Exception as e:
                # 其他异常，记录但不抛出
                image_load_failed = True
                print(f"⚠ 加载文件时发生错误: {e}")
                result = None
        
        # 如果原始方法没有加载到标注，或者我们想覆盖，尝试从 annotations 目录加载
        if filename and os.path.exists(filename):
            image_path = Path(filename)
            
            # 检查是否是图片文件
            if image_path.suffix.lower() in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tif', '.tiff']:
                # 查找 annotations 目录
                annotations_dir = _get_annotations_dir(image_path)
                
                # 即使图片加载失败，也尝试从 COCO 加载标注
                image_load_failed = (result is None)
                if image_load_failed:
                    print(f"⚠ 图片文件加载失败，但尝试从 COCO 加载标注: {filename}")
                    print(f"   提示: 图片可能损坏或格式不完整，但标注仍可正常编辑")
                    print(f"   修复方法:")
                    print(f"     1. 单个文件: ./labelme/fix_broken_jpeg.sh \"{filename}\"")
                    print(f"     2. 批量修复: ./labelme/fix_all_broken_jpegs.sh")
                
                if annotations_dir:
                    print(f"[DEBUG] 找到 annotations 目录: {annotations_dir}")
                else:
                    print(f"[DEBUG] 未找到 annotations 目录，图片路径: {image_path}")
                
                if annotations_dir:
                    # 查找对应的单个 JSON 文件（优先）
                    image_stem = image_path.stem
                    single_json_file = annotations_dir / f"{image_stem}.json"
                    
                    # 查找 COCO 格式文件（检查所有COCO文件，找到包含该图片的）
                    coco_files = [
                        annotations_dir / 'instance_train.json',
                        annotations_dir / 'instance_val.json',
                        annotations_dir / 'instance_all.json',
                    ]
                    coco_file = None
                    # 先尝试找到包含该图片的COCO文件
                    for cf in coco_files:
                        if cf.exists():
                            try:
                                with open(cf, 'r', encoding='utf-8') as f:
                                    coco_data = json.load(f)
                                # 检查该图片是否在这个COCO文件中（规范化文件名匹配）
                                image_name_normalized = Path(image_path.name).name
                                for img in coco_data.get('images', []):
                                    coco_file_name = img.get('file_name', '')
                                    coco_file_name_normalized = Path(coco_file_name).name
                                    if coco_file_name_normalized == image_name_normalized or coco_file_name == image_path.name:
                                        coco_file = cf
                                        break
                                if coco_file:
                                    break
                            except Exception as e:
                                # 如果读取失败，继续尝试下一个
                                pass
                    
                    # 如果没找到，使用第一个存在的COCO文件（向后兼容）
                    if not coco_file:
                        for cf in coco_files:
                            if cf.exists():
                                coco_file = cf
                                break
                    
                    label_data = None
                    annotation_file = None
                    
                    try:
                        # 优先从单个 JSON 文件加载
                        if single_json_file.exists():
                            print(f"[DEBUG] 找到单个 JSON 文件: {single_json_file}")
                            try:
                                # 直接读取 JSON 文件，不通过 LabelFile.load（避免 imageData 问题）
                                with open(single_json_file, 'r', encoding='utf-8') as f:
                                    label_data = json.load(f)
                                # 确保有 imageData 字段（Labelme 5.2.1 需要）
                                if 'imageData' not in label_data:
                                    label_data['imageData'] = None
                                annotation_file = single_json_file
                            except Exception as e:
                                print(f"⚠ 加载单个 JSON 失败: {e}")
                                import traceback
                                traceback.print_exc()
                        
                        # 如果单个 JSON 不存在，从 COCO 生成
                        if label_data is None and coco_file and coco_file.exists():
                            print(f"[DEBUG] 从 COCO 文件生成单个 JSON: {coco_file.name}")
                            print(f"[DEBUG] 图片文件名: {image_path.name}")
                            print(f"[DEBUG] 输出JSON路径: {single_json_file}")
                            try:
                                from coco_to_labelme_json import coco_to_labelme_json
                                # 获取图片尺寸（如果图片加载失败，从COCO获取）
                                image_width = 0
                                image_height = 0
                                if not image_load_failed and hasattr(main_window, 'image') and main_window.image:
                                    image_width = main_window.image.width()
                                    image_height = main_window.image.height()
                                
                                # 从 COCO 生成单个 JSON
                                label_data = coco_to_labelme_json(
                                    coco_file,
                                    image_path.name,
                                    single_json_file,
                                    image_width,
                                    image_height
                                )
                                
                                if label_data:
                                    annotation_file = single_json_file
                                    shapes_count = len(label_data.get('shapes', []))
                                    print(f"✓ 从 COCO 生成单个 JSON: {single_json_file.name} (包含 {shapes_count} 个标注)")
                                    
                                    # 验证文件是否真的创建了
                                    if not single_json_file.exists():
                                        print(f"⚠ 警告: JSON文件生成后不存在: {single_json_file}")
                                else:
                                    print(f"⚠ 从 COCO 生成 JSON 返回 None (图片可能不在COCO文件中)")
                            except Exception as e:
                                print(f"⚠ 从 COCO 生成 JSON 失败: {e}")
                                print(f"   图片: {image_path.name}")
                                print(f"   COCO文件: {coco_file}")
                                import traceback
                                traceback.print_exc()
                        
                        # 如果还是失败，尝试直接加载 COCO（向后兼容）
                        if label_data is None:
                            annotation_file = _find_annotation_file(image_path, annotations_dir)
                            if annotation_file and annotation_file.exists():
                                print(f"[DEBUG] 找到标注文件: {annotation_file}")
                                try:
                                    # 检查是否是 COCO 格式文件
                                    is_coco_format = annotation_file.name in [
                                        'instance_train.json', 
                                        'instance_val.json', 
                                        'instance_all.json',
                                        'annotations.json'
                                    ]
                                    
                                    if is_coco_format:
                                        # 从 COCO 格式加载
                                        label_data = _load_coco_annotation_for_image(image_path, annotation_file)
                                        # 同时生成单个 JSON 文件
                                        if label_data:
                                            try:
                                                from coco_to_labelme_json import coco_to_labelme_json
                                                coco_to_labelme_json(
                                                    annotation_file,
                                                    image_path.name,
                                                    single_json_file,
                                                    label_data.get('imageWidth', 0),
                                                    label_data.get('imageHeight', 0)
                                                )
                                                annotation_file = single_json_file
                                            except:
                                                pass
                                    else:
                                        # 直接读取 JSON 文件，不通过 LabelFile.load（避免 imageData 问题）
                                        try:
                                            with open(annotation_file, 'r', encoding='utf-8') as f:
                                                label_data = json.load(f)
                                            # 确保有 imageData 字段（Labelme 5.2.1 需要）
                                            if 'imageData' not in label_data:
                                                label_data['imageData'] = None
                                        except Exception as e:
                                            print(f"⚠ 读取 JSON 文件失败: {e}")
                                            label_data = None
                                except Exception as e:
                                    print(f"⚠ 加载标注文件失败: {e}")
                                    label_data = None
                        
                        # 无论是否有标注数据，都要加载类别列表（从 COCO 文件获取完整集合）
                        # 存储当前图片的标注文件路径，用于保存时使用
                        if annotations_dir:
                            # 总是使用单个 JSON 文件路径
                            image_stem = image_path.stem
                            single_json_file = annotations_dir / f"{image_stem}.json"
                            
                            if label_data:
                                # 如果有标注数据，使用实际找到的标注文件路径
                                if annotation_file and annotation_file.suffix == '.json' and annotation_file.name != 'instance_train.json' and annotation_file.name != 'instance_val.json':
                                    main_window._current_annotation_file = str(annotation_file)
                                else:
                                    main_window._current_annotation_file = str(single_json_file)
                            else:
                                # 如果没有标注数据，使用单个 JSON 路径（用于保存新标注）
                                main_window._current_annotation_file = str(single_json_file)
                            
                            main_window._current_annotations_dir = str(annotations_dir)
                            main_window._current_coco_file = str(coco_file) if coco_file and coco_file.exists() else None
                            
                            # 从所有 COCO 文件加载类别列表（规范：从 instance_train.json 和 instance_val.json 获取完整集合）
                            try:
                                from coco_to_labelme_json import get_all_coco_categories
                                categories = get_all_coco_categories(annotations_dir)
                                if categories:
                                    # 更新标签列表
                                    added_count = 0
                                    failed_count = 0
                                    failed_categories = []
                                    
                                    if hasattr(main_window, 'labelList') and main_window.labelList:
                                        # 获取现有标签列表
                                        existing_labels = []
                                        try:
                                            # 尝试使用 count() 方法（QListWidget）
                                            if hasattr(main_window.labelList, 'count'):
                                                for i in range(main_window.labelList.count()):
                                                    item = main_window.labelList.item(i)
                                                    if item:
                                                        existing_labels.append(item.text())
                                            else:
                                                # 尝试使用其他方式获取标签
                                                if hasattr(main_window.labelList, 'items'):
                                                    for item in main_window.labelList.items():
                                                        if hasattr(item, 'text'):
                                                            existing_labels.append(item.text())
                                        except Exception as e:
                                            print(f"⚠ 获取现有标签列表失败: {e}")
                                        
                                        # 尝试使用 labelme 的标准方法添加标签（如果存在）
                                        use_standard_method = False
                                        if hasattr(main_window, 'loadLabels'):
                                            try:
                                                # labelme 的 loadLabels 方法可能接受列表
                                                main_window.loadLabels(categories)
                                                use_standard_method = True
                                                added_count = len(categories)
                                                print(f"✓ 使用 loadLabels 方法添加了 {added_count} 个类别")
                                            except Exception as e:
                                                print(f"⚠ loadLabels 方法失败: {e}, 使用 addItem 方法")
                                        
                                        # 如果标准方法失败，逐个添加
                                        if not use_standard_method:
                                            for cat in categories:
                                                if not cat or not isinstance(cat, str):
                                                    continue
                                                
                                                # 检查是否已存在
                                                if cat in existing_labels:
                                                    continue
                                                
                                                # 使用辅助函数添加
                                                if _add_label_to_list(main_window, cat):
                                                    added_count += 1
                                                    existing_labels.append(cat)  # 更新已存在列表，避免重复
                                                else:
                                                    failed_count += 1
                                                    failed_categories.append(cat)
                                    
                                    # 输出结果
                                    if added_count > 0:
                                        print(f"✓ 从 COCO 加载类别列表: 成功添加 {added_count}/{len(categories)} 个类别")
                                        if failed_count > 0:
                                            print(f"⚠ 有 {failed_count} 个类别添加失败: {', '.join(failed_categories)}")
                                    else:
                                        print(f"⚠ 未能添加任何类别到 Label List（共 {len(categories)} 个类别）")
                                        print(f"   类别列表: {', '.join(categories)}")
                            except Exception as e:
                                print(f"⚠ 加载类别列表失败: {e}")
                                import traceback
                                traceback.print_exc()
                        
                        # 如果有标注数据，加载标注到界面
                        if label_data:
                            
                            # 检查是否需要加载（如果原始方法没有加载到标注，或者标注文件更新）
                            should_load = False
                            if not main_window.hasLabelFile():
                                should_load = True
                            else:
                                # 检查标注文件是否更新
                                try:
                                    if annotation_file:
                                        annotation_mtime = os.path.getmtime(annotation_file)
                                        image_mtime = os.path.getmtime(filename) if os.path.exists(filename) else 0
                                        if annotation_mtime > image_mtime:
                                            should_load = True
                                except:
                                    should_load = True
                            
                            if should_load:
                                # 加载标注数据到界面
                                if 'shapes' in label_data and label_data['shapes']:
                                    # 如果图片加载失败，尝试使用标注中的图片尺寸信息创建占位图片
                                    if image_load_failed:
                                        # 从标注数据中获取图片尺寸
                                        width = label_data.get('imageWidth', 0)
                                        height = label_data.get('imageHeight', 0)
                                        
                                        # 确保尺寸有效（大于0）
                                        if width <= 0 or height <= 0:
                                            # 如果尺寸无效，使用默认值
                                            width = 800
                                            height = 600
                                            print(f"⚠ JSON中的图片尺寸无效，使用默认尺寸: {width}x{height}")
                                        
                                        # 创建占位图片（确保尺寸有效）
                                        try:
                                            from PyQt5.QtGui import QPixmap, QImage
                                            
                                            # 检查当前是否有图片
                                            has_image = False
                                            if hasattr(main_window, 'image'):
                                                if main_window.image is not None:
                                                    # 检查图片尺寸是否有效
                                                    try:
                                                        img_w = main_window.image.width()
                                                        img_h = main_window.image.height()
                                                        if img_w > 0 and img_h > 0:
                                                            has_image = True
                                                    except:
                                                        pass
                                            
                                            # 如果没有有效图片，创建占位图片
                                            if not has_image:
                                                placeholder = QPixmap(int(width), int(height))
                                                if placeholder.isNull():
                                                    # 如果创建失败，使用默认尺寸
                                                    placeholder = QPixmap(800, 600)
                                                    width, height = 800, 600
                                                
                                                placeholder.fill()  # 填充白色
                                                
                                                # 使用Labelme的标准方法设置图片
                                                if hasattr(main_window, 'setImage'):
                                                    main_window.setImage(placeholder)
                                                elif hasattr(main_window, 'image'):
                                                    main_window.image = placeholder
                                                
                                                print(f"✓ 已创建占位图片（尺寸: {int(width)}x{int(height)}），标注可正常编辑和保存")
                                                print(f"   提示: 图片文件可能损坏，但标注功能完全正常")
                                        except Exception as e:
                                            print(f"⚠ 无法创建占位图片: {e}")
                                            import traceback
                                            traceback.print_exc()
                                    else:
                                        # 图片已加载，确保尺寸正确且有效
                                        try:
                                            if hasattr(main_window, 'image') and main_window.image:
                                                img_w = main_window.image.width()
                                                img_h = main_window.image.height()
                                                
                                                # 检查尺寸是否有效（避免除零错误）
                                                if img_w > 0 and img_h > 0:
                                                    # 更新图片尺寸信息
                                                    if 'imageWidth' in label_data:
                                                        label_data['imageWidth'] = img_w
                                                    if 'imageHeight' in label_data:
                                                        label_data['imageHeight'] = img_h
                                                else:
                                                    # 如果图片尺寸无效，使用JSON中的尺寸或默认值
                                                    width = label_data.get('imageWidth', 800)
                                                    height = label_data.get('imageHeight', 600)
                                                    if width <= 0:
                                                        width = 800
                                                    if height <= 0:
                                                        height = 600
                                                    
                                                    # 创建有效尺寸的占位图片
                                                    from PyQt5.QtGui import QPixmap
                                                    placeholder = QPixmap(int(width), int(height))
                                                    placeholder.fill()
                                                    if hasattr(main_window, 'setImage'):
                                                        main_window.setImage(placeholder)
                                                    print(f"⚠ 图片尺寸无效，已创建占位图片: {int(width)}x{int(height)}")
                                        except Exception as e:
                                            print(f"⚠ 检查图片尺寸时出错: {e}")
                                    
                                    # 将字典格式转换为 Shape 对象
                                    from labelme.app import Shape
                                    shapes = []
                                    for shape_data in label_data.get('shapes', []):
                                        # 验证 points 数据
                                        points = shape_data.get('points', [])
                                        if not points or len(points) < 2:
                                            continue
                                        
                                        # 确保 points 是 QPointF 对象列表
                                        try:
                                            # 转换 points 为 QPointF 对象
                                            validated_points = []
                                            for point in points:
                                                if isinstance(point, (list, tuple)) and len(point) >= 2:
                                                    # 转换为 QPointF 对象
                                                    validated_points.append(QPointF(float(point[0]), float(point[1])))
                                                elif hasattr(point, 'x') and hasattr(point, 'y'):
                                                    # 已经是 QPointF 对象
                                                    validated_points.append(point)
                                                else:
                                                    raise ValueError(f"Invalid point format: {point}")
                                            
                                            if len(validated_points) < 2:
                                                continue
                                            
                                            try:
                                                shape = Shape(
                                                    label=shape_data.get('label', ''),
                                                    shape_type=shape_data.get('shape_type', 'rectangle'),
                                                    flags=shape_data.get('flags', {})
                                                )
                                                # 验证 shape 对象创建成功
                                                if shape is None:
                                                    print(f"⚠ Shape 对象创建失败，跳过")
                                                    continue
                                                
                                                shape.points = validated_points
                                                if 'group_id' in shape_data:
                                                    shape.group_id = shape_data.get('group_id')
                                                if 'description' in shape_data:
                                                    shape.description = shape_data.get('description', '')
                                                
                                                # 再次验证 shape 对象有效
                                                if not hasattr(shape, 'points') or not shape.points:
                                                    print(f"⚠ Shape 对象无效（缺少 points），跳过")
                                                    continue
                                                
                                                shapes.append(shape)
                                            except Exception as shape_error:
                                                print(f"⚠ 创建 Shape 对象时出错: {shape_error}, 跳过")
                                                import traceback
                                                traceback.print_exc()
                                                continue
                                        except (ValueError, TypeError) as e:
                                            print(f"⚠ 跳过无效的标注点: {e}")
                                            continue
                                    
                                    if shapes:
                                        # 过滤掉 None 值，确保所有 shape 都是有效的
                                        valid_shapes = [s for s in shapes if s is not None]
                                        
                                        if not valid_shapes:
                                            print(f"⚠ 警告: 所有标注都是无效的，跳过加载")
                                        else:
                                            # 使用 loadShapes 方法加载形状（Labelme 的标准方法）
                                            if hasattr(main_window, 'loadShapes'):
                                                # 使用 loadShapes 加载（这会自动添加到界面）
                                                try:
                                                    main_window.loadShapes(valid_shapes, replace=True)
                                                except Exception as e:
                                                    print(f"⚠ loadShapes 失败: {e}, 尝试直接设置")
                                                    import traceback
                                                    traceback.print_exc()
                                                    # 如果 loadShapes 失败，尝试直接设置
                                                    if hasattr(main_window, 'shapes'):
                                                        main_window.shapes = valid_shapes
                                                    if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'loadShapes'):
                                                        main_window.canvas.loadShapes(valid_shapes, replace=True)
                                            else:
                                                # 如果没有 loadShapes，直接设置 shapes
                                                if hasattr(main_window, 'shapes'):
                                                    main_window.shapes = valid_shapes
                                                if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'loadShapes'):
                                                    main_window.canvas.loadShapes(valid_shapes, replace=True)
                                            
                                            # 确保 canvas 中的 shapes 列表不包含 None
                                            if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
                                                canvas_shapes = main_window.canvas.shapes
                                                if canvas_shapes:
                                                    # 过滤掉 None
                                                    main_window.canvas.shapes = [s for s in canvas_shapes if s is not None]
                                            
                                            # 确保 main_window.shapes 也不包含 None
                                            if hasattr(main_window, 'shapes'):
                                                main_window_shapes = main_window.shapes
                                                if main_window_shapes:
                                                    main_window.shapes = [s for s in main_window_shapes if s is not None]
                                        
                                        # 加载 flags
                                        if 'flags' in label_data and hasattr(main_window, 'loadFlags'):
                                            main_window.loadFlags(label_data.get('flags', {}))
                                        
                                            # 更新标签列表
                                            # Labelme 的 loadShapes 方法应该会自动更新标签列表
                                            # 如果 loadShapes 没有自动更新，我们尝试手动更新
                                            try:
                                                # 方法1: 尝试使用 labelList widget（如果存在）
                                                if hasattr(main_window, 'labelList') and main_window.labelList:
                                                    # 使用 valid_shapes 而不是 shapes
                                                    labels = [shape.label for shape in valid_shapes if shape and hasattr(shape, 'label') and shape.label]
                                                    unique_labels = list(set(labels))
                                                    for label in unique_labels:
                                                        if label:
                                                            # 检查标签是否已存在（兼容不同的 labelList 实现）
                                                            existing_labels = []
                                                            try:
                                                                # 尝试使用 count() 方法（QListWidget）
                                                                if hasattr(main_window.labelList, 'count'):
                                                                    for i in range(main_window.labelList.count()):
                                                                        item = main_window.labelList.item(i)
                                                                        if item:
                                                                            existing_labels.append(item.text())
                                                                else:
                                                                    # 尝试使用其他方式获取标签
                                                                    if hasattr(main_window.labelList, 'items'):
                                                                        for item in main_window.labelList.items():
                                                                            if hasattr(item, 'text'):
                                                                                existing_labels.append(item.text())
                                                            except Exception as e:
                                                                # 如果获取失败，直接添加（可能重复，但不影响功能）
                                                                pass
                                                            
                                                            if label not in existing_labels:
                                                                # 使用辅助函数添加
                                                                _add_label_to_list(main_window, label)
                                            except Exception as e:
                                                # 忽略标签列表更新错误，不影响标注加载
                                                # loadShapes 应该已经处理了标签列表
                                                pass
                                    
                                    # 不创建 LabelFile 对象（完全避免 imageData 问题）
                                    # Labelme 会在需要时自动创建，我们不需要手动创建
                                    # 这样可以避免 PIL load() 失败导致的错误
                                    
                                    # 加载后清理 labelList 中的 None shape item
                                    if hasattr(main_window, 'labelList') and main_window.labelList:
                                        try:
                                            items_to_remove = []
                                            if hasattr(main_window.labelList, 'count'):
                                                for i in range(main_window.labelList.count() - 1, -1, -1):
                                                    item = main_window.labelList.item(i)
                                                    if item:
                                                        try:
                                                            if hasattr(item, 'shape'):
                                                                shape = item.shape()
                                                                if shape is None:
                                                                    items_to_remove.append(i)
                                                        except Exception:
                                                            # 如果获取 shape 失败，也移除
                                                            items_to_remove.append(i)
                                            
                                            # 从后往前删除
                                            for i in items_to_remove:
                                                try:
                                                    removed_item = main_window.labelList.takeItem(i)
                                                    if removed_item:
                                                        del removed_item
                                                except Exception:
                                                    pass
                                            
                                            if items_to_remove:
                                                print(f"[DEBUG] 加载后清理了 {len(items_to_remove)} 个 None shape item")
                                        except Exception as e:
                                            print(f"⚠ 加载后清理 None shape item 时出错: {e}")
                                    
                                    # 刷新界面
                                    main_window.canvas.update()
                                    if hasattr(main_window, 'updateComboBox'):
                                        main_window.updateComboBox()
                                    
                                    format_type = "COCO" if (coco_file and coco_file.exists()) else "Labelme"
                                    print(f"✓ 从 {annotations_dir} 自动加载标注 ({format_type} 格式): {annotation_file.name if annotation_file else 'N/A'}, 包含 {len(label_data.get('shapes', []))} 个标注")
                    except Exception as e:
                        print(f"⚠ 加载标注文件失败: {e}")
                        import traceback
                        traceback.print_exc()
                else:
                    # 没有找到标注文件，清除存储的路径
                    if hasattr(main_window, '_current_annotation_file'):
                        delattr(main_window, '_current_annotation_file')
                    if hasattr(main_window, '_current_annotations_dir'):
                        delattr(main_window, '_current_annotations_dir')
        
        return result
    
    # 替换 loadFile 方法
    main_window.loadFile = new_load_file


def _convert_labelme_to_coco_annotation(shape, image_id: int, annotation_id: int, category_id: int, image_width: int, image_height: int) -> Optional[Dict]:
    """
    将 Labelme shape 转换为 COCO 格式的 annotation
    
    Args:
        shape: Labelme shape 对象或字典
        image_id: 图片 ID
        annotation_id: 标注 ID
        category_id: 类别 ID
        image_width: 图片宽度
        image_height: 图片高度
        
    Returns:
        COCO 格式的 annotation 字典
    """
    # 获取 shape 数据
    if hasattr(shape, 'label'):
        label = shape.label
        shape_type = shape.shape_type if hasattr(shape, 'shape_type') else 'polygon'
        points = shape.points if hasattr(shape, 'points') else []
    else:
        label = shape.get('label', 'unknown')
        shape_type = shape.get('shape_type', 'polygon')
        points = shape.get('points', [])
    
    if not points:
        return None
    
    annotation = {
        "id": annotation_id,
        "image_id": image_id,
        "category_id": category_id,
        "iscrowd": 0
    }
    
    if shape_type == 'rectangle':
        # 矩形框：转换为 bbox [x, y, width, height]
        if len(points) >= 2:
            x_coords = [p[0] if isinstance(p, (list, tuple)) else p.x() for p in points]
            y_coords = [p[1] if isinstance(p, (list, tuple)) else p.y() for p in points]
            
            x_min = max(0, min(x_coords))
            y_min = max(0, min(y_coords))
            x_max = min(image_width, max(x_coords))
            y_max = min(image_height, max(y_coords))
            
            bbox = [x_min, y_min, x_max - x_min, y_max - y_min]
            if bbox[2] > 0 and bbox[3] > 0:
                annotation["bbox"] = bbox
                annotation["area"] = bbox[2] * bbox[3]
                
                # 从bbox生成顺时针的segmentation（矩形四个角点）
                # 顺时针顺序：左上 -> 右上 -> 右下 -> 左下
                x, y, w, h = bbox
                annotation["segmentation"] = [[
                    float(x),           # 左上 x
                    float(y),           # 左上 y
                    float(x + w),       # 右上 x
                    float(y),           # 右上 y
                    float(x + w),       # 右下 x
                    float(y + h),       # 右下 y
                    float(x),           # 左下 x
                    float(y + h)        # 左下 y
                ]]
            else:
                return None
        else:
            return None
            
    elif shape_type == 'polygon':
        # 多边形：转换为 segmentation
        segmentation = []
        for point in points:
            if isinstance(point, (list, tuple)):
                x, y = point[0], point[1]
            else:
                x, y = point.x(), point.y()
            x = max(0, min(x, image_width))
            y = max(0, min(y, image_height))
            segmentation.extend([float(x), float(y)])
        
        if len(segmentation) >= 6:  # 至少3个点
            annotation["segmentation"] = [segmentation]
            
            # 计算边界框
            x_coords = [segmentation[i] for i in range(0, len(segmentation), 2)]
            y_coords = [segmentation[i] for i in range(1, len(segmentation), 2)]
            x_min = max(0, min(x_coords))
            y_min = max(0, min(y_coords))
            x_max = min(image_width, max(x_coords))
            y_max = min(image_height, max(y_coords))
            
            bbox = [x_min, y_min, x_max - x_min, y_max - y_min]
            annotation["bbox"] = bbox
            annotation["area"] = bbox[2] * bbox[3]
        else:
            return None
    else:
        return None
    
    return annotation


def _update_coco_file(coco_file: Path, image_path: Path, shapes: List, image_width: int, image_height: int) -> bool:
    """
    更新 COCO 格式文件，添加或更新指定图片的标注
    
    Args:
        coco_file: COCO 格式文件路径
        image_path: 图片文件路径
        shapes: Labelme shapes 列表
        image_width: 图片宽度
        image_height: 图片高度
        
    Returns:
        是否成功
    """
    try:
        # 加载现有的 COCO 数据
        if coco_file.exists():
            with open(coco_file, 'r', encoding='utf-8') as f:
                coco_data = json.load(f)
        else:
            # 创建新的 COCO 数据结构
            coco_data = {
                "info": {
                    "description": "Auto Generated Dataset with Labelme",
                    "version": "1.0",
                    "year": 2024,
                    "contributor": "Labelme Plugin",
                    "date_created": ""
                },
                "licenses": [{"id": 0, "name": "Unknown", "url": None}],
                "images": [],
                "annotations": [],
                "categories": []
            }
        
        # 获取或创建类别映射
        categories = {cat['name']: cat['id'] for cat in coco_data.get('categories', [])}
        category_names = {cat['id']: cat['name'] for cat in coco_data.get('categories', [])}
        
        # 获取所有标签
        labels = set()
        for shape in shapes:
            if hasattr(shape, 'label'):
                labels.add(shape.label)
            else:
                labels.add(shape.get('label', 'unknown'))
        
        # 添加新类别
        next_category_id = max(categories.values()) + 1 if categories else 1
        for label in labels:
            if label not in categories:
                categories[label] = next_category_id
                category_names[next_category_id] = label
                coco_data['categories'].append({
                    "id": next_category_id,
                    "name": label,
                    "supercategory": "none"
                })
                next_category_id += 1
        
        # 查找或创建图片信息
        image_name = image_path.name
        image_info = None
        image_id = None
        
        for img in coco_data.get('images', []):
            # 规范化文件名匹配
            image_name_normalized = Path(image_name).name
            coco_file_name = img.get('file_name', '')
            coco_file_name_normalized = Path(coco_file_name).name
            if coco_file_name_normalized == image_name_normalized or coco_file_name == image_name:
                image_info = img
                image_id = img['id']
                break
        
        if not image_info:
            # 创建新的图片信息
            image_id = max([img['id'] for img in coco_data.get('images', [])], default=0) + 1
            image_info = {
                "id": image_id,
                "file_name": image_name,
                "width": image_width,
                "height": image_height
            }
            coco_data['images'].append(image_info)
        else:
            # 更新图片信息
            image_info['width'] = image_width
            image_info['height'] = image_height
        
        # 删除该图片的旧标注
        coco_data['annotations'] = [
            ann for ann in coco_data.get('annotations', [])
            if ann.get('image_id') != image_id
        ]
        
        # 添加新标注
        next_annotation_id = max([ann['id'] for ann in coco_data.get('annotations', [])], default=0) + 1
        
        for shape in shapes:
            # 获取类别 ID
            if hasattr(shape, 'label'):
                label = shape.label
            else:
                label = shape.get('label', 'unknown')
            
            category_id = categories.get(label, 1)
            
            # 转换标注
            annotation = _convert_labelme_to_coco_annotation(
                shape, image_id, next_annotation_id, category_id, image_width, image_height
            )
            
            if annotation:
                coco_data['annotations'].append(annotation)
                next_annotation_id += 1
        
        # 更新日期
        from datetime import datetime
        coco_data['info']['date_created'] = datetime.now().strftime('%Y-%m-%d')
        
        # 安全保存文件（先写临时文件，再替换，确保原子性）
        import tempfile
        import shutil
        
        # 创建临时文件
        temp_file = coco_file.with_suffix('.tmp')
        try:
            # 先写入临时文件
            with open(temp_file, 'w', encoding='utf-8') as f:
                json.dump(coco_data, f, ensure_ascii=False, indent=2)
                # 确保数据写入磁盘（在with块内调用）
                f.flush()
                os.fsync(f.fileno())
            
            # 原子性替换（在 POSIX 系统上，rename 是原子操作）
            shutil.move(str(temp_file), str(coco_file))
            
            print(f"[DEBUG] 已安全保存 COCO 文件: {coco_file.name} (包含 {len(coco_data.get('images', []))} 张图片, {len(coco_data.get('annotations', []))} 个标注)")
            
            return True
        except Exception as e:
            # 如果保存失败，删除临时文件
            if temp_file.exists():
                try:
                    temp_file.unlink()
                except:
                    pass
            raise
        
    except Exception as e:
        print(f"⚠ 更新 COCO 文件失败 {coco_file}: {e}")
        import traceback
        traceback.print_exc()
        return False


def _hook_save_file(main_window):
    """
    拦截 Labelme 的 saveFile 方法，将标注保存到 annotations 目录
    
    Args:
        main_window: Labelme 主窗口实例
    """
    # 保存原始的 saveFile 和 saveLabels 方法
    original_save_file = main_window.saveFile
    original_save_labels = None
    if hasattr(main_window, 'saveLabels'):
        original_save_labels = main_window.saveLabels
    
    def new_save_labels(filename):
        """
        增强的 saveLabels 方法，过滤掉 None 的 shape
        """
        # 首先收集所有有效的 shapes（在清理之前）
        valid_shapes_backup = []
        if hasattr(main_window, 'shapes') and main_window.shapes:
            valid_shapes_backup = [s for s in main_window.shapes if s is not None and hasattr(s, 'label') and hasattr(s, 'points')]
        elif hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
            valid_shapes_backup = [s for s in main_window.canvas.shapes if s is not None and hasattr(s, 'label') and hasattr(s, 'points')]
        
        print(f"[DEBUG] 保存前收集到 {len(valid_shapes_backup)} 个有效 shapes 作为备份")
        
        # 在保存前，清理 labelList 中所有 shape 为 None 的 item
        if hasattr(main_window, 'labelList') and main_window.labelList:
            try:
                items_to_remove = []
                if hasattr(main_window.labelList, 'count'):
                    # 从后往前遍历，避免索引变化
                    for i in range(main_window.labelList.count() - 1, -1, -1):
                        item = main_window.labelList.item(i)
                        if item:
                            try:
                                # 尝试获取 shape
                                shape = None
                                if hasattr(item, 'shape'):
                                    try:
                                        shape = item.shape()
                                    except:
                                        pass
                                
                                # 如果 shape 是 None 或获取失败，标记为删除
                                if shape is None:
                                    items_to_remove.append(i)
                            except Exception as e:
                                # 如果获取 shape 失败，也移除
                                print(f"⚠ 获取 item shape 失败: {e}")
                                items_to_remove.append(i)
                
                # 从后往前删除，避免索引变化
                for i in items_to_remove:
                    try:
                        item = main_window.labelList.takeItem(i)
                        if item:
                            # 尝试断开所有连接
                            try:
                                item.setData(None)
                            except:
                                pass
                            del item
                    except Exception as e:
                        print(f"⚠ 删除 item 时出错: {e}")
                
                # 刷新 labelList
                try:
                    main_window.labelList.update()
                except:
                    pass
                
                if items_to_remove:
                    print(f"⚠ 已清理 {len(items_to_remove)} 个无效的 labelList item（shape 为 None）")
            except Exception as e:
                print(f"⚠ 清理 labelList 时出错: {e}")
                import traceback
                traceback.print_exc()
        
        # 确保 shapes 列表不包含 None
        if hasattr(main_window, 'shapes'):
            original_shapes = main_window.shapes
            main_window.shapes = [s for s in original_shapes if s is not None and hasattr(s, 'label')]
            if len(main_window.shapes) != len(original_shapes):
                print(f"⚠ 已清理 {len(original_shapes) - len(main_window.shapes)} 个无效的 shape")
        
        if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
            original_canvas_shapes = main_window.canvas.shapes
            main_window.canvas.shapes = [s for s in original_canvas_shapes if s is not None and hasattr(s, 'label')]
            if len(main_window.canvas.shapes) != len(original_canvas_shapes):
                print(f"⚠ 已清理 {len(original_canvas_shapes) - len(main_window.canvas.shapes)} 个无效的 canvas shape")
        
        # 再次验证 labelList 中没有 None shape（更彻底的清理）
        if hasattr(main_window, 'labelList') and main_window.labelList:
            try:
                if hasattr(main_window.labelList, 'count'):
                    # 多次清理，直到没有 None shape
                    max_iterations = 5
                    for iteration in range(max_iterations):
                        items_to_remove_again = []
                        for i in range(main_window.labelList.count() - 1, -1, -1):
                            item = main_window.labelList.item(i)
                            if item:
                                try:
                                    if hasattr(item, 'shape'):
                                        shape = item.shape()
                                        if shape is None:
                                            items_to_remove_again.append(i)
                                    else:
                                        # 如果没有 shape 方法，也删除
                                        items_to_remove_again.append(i)
                                except:
                                    # 如果获取失败，也删除
                                    items_to_remove_again.append(i)
                        
                        if not items_to_remove_again:
                            break  # 没有需要删除的，退出循环
                        
                        # 删除找到的无效项
                        for i in items_to_remove_again:
                            try:
                                removed_item = main_window.labelList.takeItem(i)
                                if removed_item:
                                    del removed_item
                            except:
                                pass
                        
                        if items_to_remove_again:
                            print(f"⚠ 第 {iteration + 1} 次清理：删除了 {len(items_to_remove_again)} 个无效 item")
            except Exception as e:
                print(f"⚠ 二次清理 labelList 时出错: {e}")
        
        # 在调用原始方法前，先检查是否有有效的 shapes
        valid_shapes = []
        
        # 优先级1: 使用备份的 shapes（在清理前收集的）
        if valid_shapes_backup:
            valid_shapes = valid_shapes_backup
            print(f"[DEBUG] 使用备份的 {len(valid_shapes)} 个有效 shapes")
        
        # 优先级2: 从 main_window.shapes 获取
        if not valid_shapes and hasattr(main_window, 'shapes') and main_window.shapes:
            valid_shapes = [s for s in main_window.shapes if s is not None and hasattr(s, 'label') and hasattr(s, 'points')]
            print(f"[DEBUG] main_window.shapes 中有 {len(valid_shapes)} 个有效 shapes（总共 {len(main_window.shapes)} 个）")
        
        # 优先级3: 从 canvas.shapes 获取
        if not valid_shapes and hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
            valid_shapes = [s for s in main_window.canvas.shapes if s is not None and hasattr(s, 'label') and hasattr(s, 'points')]
            print(f"[DEBUG] canvas.shapes 中有 {len(valid_shapes)} 个有效 shapes")
        
        # 优先级4: 从 labelList 获取（清理后可能还有有效的）
        if not valid_shapes and hasattr(main_window, 'labelList') and main_window.labelList:
            if hasattr(main_window.labelList, 'count'):
                for i in range(main_window.labelList.count()):
                    item = main_window.labelList.item(i)
                    if item and hasattr(item, 'shape'):
                        try:
                            shape = item.shape()
                            if shape is not None and hasattr(shape, 'label') and hasattr(shape, 'points'):
                                valid_shapes.append(shape)
                        except:
                            pass
                print(f"[DEBUG] labelList 中有 {len(valid_shapes)} 个有效 shapes")
        
        # 如果找到了有效的 shapes，确保它们被设置到正确的位置
        if valid_shapes:
            # 确保 main_window.shapes 包含这些有效的 shapes
            if hasattr(main_window, 'shapes'):
                main_window.shapes = valid_shapes
            if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
                main_window.canvas.shapes = valid_shapes
            print(f"✓ 找到 {len(valid_shapes)} 个有效 shapes，准备保存")
        else:
            print(f"⚠ 警告：没有找到任何有效的 shapes（可能图片没有标注），但继续尝试保存")
        
        # 最后一次验证：确保 labelList 中完全没有 None shape
        if hasattr(main_window, 'labelList') and main_window.labelList:
            try:
                if hasattr(main_window.labelList, 'count'):
                    # 构建一个临时的有效 item 列表
                    valid_items = []
                    for i in range(main_window.labelList.count()):
                        item = main_window.labelList.item(i)
                        if item:
                            try:
                                if hasattr(item, 'shape'):
                                    shape = item.shape()
                                    if shape is not None and hasattr(shape, 'other_data'):
                                        valid_items.append(item)
                            except:
                                pass
                    
                    # 如果有效 items 数量与 labelList 数量不一致，说明还有无效的
                    if len(valid_items) != main_window.labelList.count():
                        print(f"[DEBUG] 发现 labelList 中有无效项，清理前: {main_window.labelList.count()}, 有效: {len(valid_items)}")
                        # 清空 labelList 并重新添加有效项
                        main_window.labelList.clear()
                        for item in valid_items:
                            main_window.labelList.addItem(item)
                        print(f"[DEBUG] 清理后 labelList 数量: {main_window.labelList.count()}")
            except Exception as e:
                print(f"⚠ 最终清理 labelList 时出错: {e}")
        
        # 调用原始 saveLabels 方法
        if original_save_labels:
            try:
                return original_save_labels(filename)
            except AttributeError as e:
                if "'NoneType' object has no attribute 'other_data'" in str(e):
                    print(f"⚠ 保存时仍然遇到 None shape 错误")
                    print(f"   尝试使用有效的 shapes 手动保存...")
                    
                    # 如果找到了有效的 shapes，尝试手动保存
                    if valid_shapes:
                        try:
                            # 手动构建保存数据
                            from labelme.app import LabelFile
                            flags = main_window.flags if hasattr(main_window, 'flags') else {}
                            
                            # 获取当前文件信息
                            current_file = main_window.filename if hasattr(main_window, 'filename') else filename
                            if not current_file:
                                print(f"⚠ 无法获取当前文件路径")
                                return False
                            
                            # 构建标注数据
                            label_data = {
                                'version': '5.2.1',
                                'flags': flags,
                                'shapes': []
                            }
                            
                            # 转换 shapes 为字典格式
                            for shape in valid_shapes:
                                if hasattr(shape, 'toDict'):
                                    shape_dict = shape.toDict()
                                else:
                                    shape_dict = {
                                        'label': getattr(shape, 'label', ''),
                                        'points': [],
                                        'group_id': getattr(shape, 'group_id', None),
                                        'shape_type': getattr(shape, 'shape_type', 'rectangle'),
                                        'flags': getattr(shape, 'flags', {}),
                                    }
                                    # 转换 points
                                    points = getattr(shape, 'points', [])
                                    for point in points:
                                        if hasattr(point, 'x') and hasattr(point, 'y'):
                                            shape_dict['points'].append([float(point.x()), float(point.y())])
                                        elif isinstance(point, (list, tuple)) and len(point) >= 2:
                                            shape_dict['points'].append([float(point[0]), float(point[1])])
                                
                                label_data['shapes'].append(shape_dict)
                            
                            # 获取图片尺寸
                            image_width = 0
                            image_height = 0
                            if hasattr(main_window, 'image') and main_window.image:
                                image_width = main_window.image.width()
                                image_height = main_window.image.height()
                            
                            label_data['imagePath'] = current_file
                            label_data['imageWidth'] = image_width
                            label_data['imageHeight'] = image_height
                            
                            # 保存到文件
                            import json
                            with open(filename, 'w', encoding='utf-8') as f:
                                json.dump(label_data, f, ensure_ascii=False, indent=2)
                            
                            print(f"✓ 手动保存成功：{len(valid_shapes)} 个标注已保存到 {filename}")
                            
                            # 标记为已保存
                            if hasattr(main_window, 'setDirty'):
                                try:
                                    # 尝试不同的调用方式
                                    import inspect
                                    sig = inspect.signature(main_window.setDirty)
                                    if len(sig.parameters) == 0:
                                        main_window.setDirty()
                                    else:
                                        main_window.setDirty(False)
                                except:
                                    # 如果调用失败，尝试无参数调用
                                    try:
                                        main_window.setDirty()
                                    except:
                                        pass
                            
                            return True
                        except Exception as e2:
                            print(f"⚠ 手动保存失败: {e2}")
                            import traceback
                            traceback.print_exc()
                            return False
                    else:
                        print(f"⚠ 没有有效的 shapes，无法保存")
                        return False
                else:
                    raise
        else:
            return False
    
    # 替换 saveLabels 方法（如果存在）
    if original_save_labels:
        main_window.saveLabels = new_save_labels
    
    def new_save_file(filename=None):
        """
        增强的 saveFile 方法，支持保存到 annotations 目录
        """
        # 获取当前打开的图片文件
        current_file = main_window.filename if hasattr(main_window, 'filename') else filename or None
        
        if not current_file:
            # 没有当前文件，使用原始保存方法
            return original_save_file(filename)
        
        image_path = Path(current_file)
        
        # 检查是否是图片文件
        if image_path.suffix.lower() not in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tif', '.tiff']:
            return original_save_file(filename)
        
        # 查找或创建 annotations 目录
        annotations_dir = None
        coco_file = None
        
        # 确定数据集目录（当前打开图片所在目录）
        dataset_dir = None
        if image_path.parent.name == 'images':
            dataset_dir = image_path.parent.parent
        else:
            dataset_dir = image_path.parent
        
        # 自动创建 annotations 子目录
        annotations_dir = dataset_dir / 'annotations'
        if not annotations_dir.exists():
            annotations_dir.mkdir(parents=True, exist_ok=True)
            print(f"✓ 自动创建 annotations 目录: {annotations_dir}")
        
        # 获取 COCO 文件路径（如果存在）
        if hasattr(main_window, '_current_coco_file') and main_window._current_coco_file:
            coco_file = Path(main_window._current_coco_file)
        
        # 查找 COCO 格式文件（优先查找包含该图片的 COCO 文件）
        coco_files = [
            annotations_dir / 'instance_train.json',
            annotations_dir / 'instance_val.json',
            annotations_dir / 'instance_all.json',
        ]
        
        # 如果还没有找到 COCO 文件，检查该图片是否在某个 COCO 文件中
        if not coco_file:
            for cf in coco_files:
                if cf.exists():
                    try:
                        with open(cf, 'r', encoding='utf-8') as f:
                            coco_data = json.load(f)
                        for img in coco_data.get('images', []):
                            # 规范化文件名匹配
                            image_name_normalized = Path(image_path.name).name
                            coco_file_name = img.get('file_name', '')
                            coco_file_name_normalized = Path(coco_file_name).name
                            if coco_file_name_normalized == image_name_normalized or coco_file_name == image_path.name:
                                coco_file = cf
                                break
                    except:
                        pass
                    if coco_file:
                        break
        
        # 直接保存到自动创建的 annotations 目录（不再询问用户）
        # annotations_dir 已经自动创建，直接使用
        
        # 如果找到了 annotations 目录，保存到那里
        if annotations_dir:
            # 首先查找该图片在哪个 COCO 文件中（优先查找包含该图片的文件）
            if coco_file is None:
                # 查找 COCO 格式文件（优先查找包含该图片的 COCO 文件）
                coco_files = [
                    annotations_dir / 'instance_train.json',
                    annotations_dir / 'instance_val.json',
                    annotations_dir / 'instance_all.json',
                ]
                
                image_name_normalized = Path(image_path.name).name
                
                # 首先检查该图片是否在某个 COCO 文件中
                for cf in coco_files:
                    if cf.exists():
                        try:
                            with open(cf, 'r', encoding='utf-8') as f:
                                coco_data = json.load(f)
                            for img in coco_data.get('images', []):
                                # 规范化文件名匹配
                                coco_file_name = img.get('file_name', '')
                                coco_file_name_normalized = Path(coco_file_name).name
                                if coco_file_name_normalized == image_name_normalized or coco_file_name == image_path.name:
                                    coco_file = cf
                                    print(f"[DEBUG] 找到图片在 COCO 文件中: {coco_file.name}")
                                    break
                        except Exception as e:
                            print(f"⚠ 读取 COCO 文件 {cf} 时出错: {e}")
                            pass
                        if coco_file:
                            break
                
                # 如果找到了包含该图片的 COCO 文件，直接使用它
                if coco_file:
                    print(f"✓ 图片已在 {coco_file.name} 中，将保存到该文件")
                else:
                    # 如果没找到，询问用户要保存到哪个 COCO 文件（train 或 val）
                    print(f"[DEBUG] 图片不在任何 COCO 文件中，询问用户选择")
                    
                    # 检查是否有用户偏好设置
                    coco_file_preference = None
                    if hasattr(main_window, '_coco_file_preference'):
                        coco_file_preference = main_window._coco_file_preference
                    
                    if coco_file_preference is None:
                        # 询问用户选择
                        msg_coco = QMessageBox()
                        msg_coco.setWindowTitle("选择 COCO 文件")
                        msg_coco.setText("选择要同步到的 COCO 文件：")
                        msg_coco.setInformativeText(f"图片 {image_path.name} 不在任何 COCO 文件中。\n\n选择要保存到训练集（train）或验证集（val）？\n\n单个 JSON 文件会同时保存，并同步到选择的 COCO 文件中。")
                        
                        train_btn = msg_coco.addButton("instance_train.json（训练集）", QMessageBox.AcceptRole)
                        val_btn = msg_coco.addButton("instance_val.json（验证集）", QMessageBox.RejectRole)
                        always_train_btn = msg_coco.addButton("总是保存到 train（不再询问）", QMessageBox.YesRole)
                        always_val_btn = msg_coco.addButton("总是保存到 val（不再询问）", QMessageBox.NoRole)
                        
                        result_coco = msg_coco.exec_()
                        
                        if result_coco == QMessageBox.YesRole:
                            # 总是保存到 train
                            coco_file_preference = 'train'
                            main_window._coco_file_preference = 'train'
                        elif result_coco == QMessageBox.NoRole:
                            # 总是保存到 val
                            coco_file_preference = 'val'
                            main_window._coco_file_preference = 'val'
                        elif msg_coco.clickedButton() == train_btn:
                            # 本次保存到 train
                            coco_file_preference = 'train'
                        else:
                            # 本次保存到 val
                            coco_file_preference = 'val'
                    
                    # 根据偏好设置 COCO 文件路径
                    if coco_file_preference == 'train':
                        coco_file = annotations_dir / 'instance_train.json'
                    elif coco_file_preference == 'val':
                        coco_file = annotations_dir / 'instance_val.json'
                    else:
                        # 默认使用 train
                        coco_file = annotations_dir / 'instance_train.json'
            
            # 确保 COCO 文件路径已设置
            if coco_file is None:
                coco_file = annotations_dir / 'instance_train.json'
            
            # 存储当前选择的 COCO 文件，以便下次加载时使用
            main_window._current_coco_file = str(coco_file)
            # 确保目录存在
            annotations_dir.mkdir(parents=True, exist_ok=True)
            
            try:
                # 获取当前标注数据（从多个来源获取，确保获取到有效的shapes）
                shapes = []
                
                # 优先级1: 从 main_window.shapes 获取
                if hasattr(main_window, 'shapes') and main_window.shapes:
                    shapes = [s for s in main_window.shapes if s is not None and hasattr(s, 'label') and hasattr(s, 'points')]
                
                # 优先级2: 从 canvas.shapes 获取（如果 main_window.shapes 为空）
                if not shapes and hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
                    shapes = [s for s in main_window.canvas.shapes if s is not None and hasattr(s, 'label') and hasattr(s, 'points')]
                
                # 优先级3: 从 labelList 获取（作为最后手段）
                if not shapes and hasattr(main_window, 'labelList') and main_window.labelList:
                    if hasattr(main_window.labelList, 'count'):
                        for i in range(main_window.labelList.count()):
                            item = main_window.labelList.item(i)
                            if item and hasattr(item, 'shape'):
                                try:
                                    shape = item.shape()
                                    if shape is not None and hasattr(shape, 'label') and hasattr(shape, 'points'):
                                        shapes.append(shape)
                                except:
                                    pass
                
                print(f"[DEBUG] new_save_file: 获取到 {len(shapes)} 个有效 shapes")
                
                # 确保 main_window.shapes 包含这些有效的 shapes
                if shapes and hasattr(main_window, 'shapes'):
                    main_window.shapes = shapes
                if shapes and hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
                    main_window.canvas.shapes = shapes
                
                # 获取图片尺寸
                image_width = 0
                image_height = 0
                try:
                    if hasattr(main_window, 'image') and main_window.image:
                        image_width = main_window.image.width()
                        image_height = main_window.image.height()
                    else:
                        from PIL import Image
                        with Image.open(current_file) as img:
                            image_width = img.width
                            image_height = img.height
                except:
                    # 如果无法获取尺寸，尝试从标注数据中获取
                    if hasattr(main_window, 'labelFile') and main_window.labelFile:
                        try:
                            label_data = main_window.labelFile.shapes
                            if hasattr(label_data, 'imageWidth'):
                                image_width = label_data.imageWidth
                            if hasattr(label_data, 'imageHeight'):
                                image_height = label_data.imageHeight
                        except:
                            pass
                
                # 总是保存到单个 JSON 文件（不直接保存到 COCO）
                image_stem = image_path.stem
                annotation_file = annotations_dir / f"{image_stem}.json"
                
                # 存储标注文件路径，以便下次加载时使用
                main_window._current_annotation_file = str(annotation_file)
                main_window._current_annotations_dir = str(annotations_dir)
                if coco_file:
                    main_window._current_coco_file = str(coco_file)
                
                from labelme.app import LabelFile
                flags = main_window.flags if hasattr(main_window, 'flags') else {}
                
                # imagePath 需要相对于 JSON 文件的位置，指向 images 目录
                # JSON在 annotations/xxx.json，图片在 images/xxx.jpg
                # 所以 imagePath 应该是 ../images/xxx.jpg（相对于annotations目录）
                # 使用 Path 处理中文文件名
                image_path_obj = Path(current_file)
                image_path_relative = f"../images/{image_path_obj.name}"
                
                # 构建标注数据字典
                label_data = {
                    'version': '5.2.1',  # 使用当前版本号
                    'flags': flags,
                    'shapes': []
                }
                
                # 添加 shapes（转换为可序列化的格式）
                shapes_added = 0
                for shape in shapes:
                    try:
                        if hasattr(shape, 'toDict'):
                            shape_dict = shape.toDict()
                        else:
                            shape_dict = {
                                'label': getattr(shape, 'label', ''),
                                'points': [],
                                'group_id': getattr(shape, 'group_id', None),
                                'shape_type': getattr(shape, 'shape_type', 'rectangle'),
                                'flags': getattr(shape, 'flags', {}),
                            }
                            # 转换 points（QPointF 转为列表）
                            points = getattr(shape, 'points', [])
                            for point in points:
                                if hasattr(point, 'x') and hasattr(point, 'y'):
                                    shape_dict['points'].append([float(point.x()), float(point.y())])
                                elif isinstance(point, (list, tuple)) and len(point) >= 2:
                                    shape_dict['points'].append([float(point[0]), float(point[1])])
                            
                            if hasattr(shape, 'description'):
                                shape_dict['description'] = shape.description
                        
                        # 验证 shape_dict 有效
                        if shape_dict.get('label') and len(shape_dict.get('points', [])) >= 2:
                            label_data['shapes'].append(shape_dict)
                            shapes_added += 1
                        else:
                            print(f"⚠ 跳过无效的shape: label={shape_dict.get('label')}, points={len(shape_dict.get('points', []))}")
                    except Exception as e:
                        print(f"⚠ 转换shape时出错: {e}")
                        import traceback
                        traceback.print_exc()
                        continue
                
                print(f"[DEBUG] new_save_file: 成功转换 {shapes_added}/{len(shapes)} 个shapes到label_data")
                
                # 添加图片路径信息
                # imagePath 需要相对于 JSON 文件的位置，指向 images 目录
                # JSON在 annotations/xxx.json，图片在 images/xxx.jpg
                # 所以 imagePath 应该是 ../images/xxx.jpg（相对于annotations目录）
                label_data['imagePath'] = image_path_relative
                label_data['imageWidth'] = image_width
                label_data['imageHeight'] = image_height
                # 不保存 imageData 字段（即使为 None 也不保存，减少文件大小）
                if 'imageData' in label_data:
                    del label_data['imageData']
                
                # 在保存前，清理 labelList 中所有 shape 为 None 的 item（避免保存时出错）
                if hasattr(main_window, 'labelList') and main_window.labelList:
                    try:
                        items_to_remove = []
                        if hasattr(main_window.labelList, 'count'):
                            for i in range(main_window.labelList.count()):
                                item = main_window.labelList.item(i)
                                if item:
                                    try:
                                        shape = item.shape() if hasattr(item, 'shape') else None
                                        if shape is None:
                                            items_to_remove.append(i)
                                    except:
                                        # 如果获取 shape 失败，也移除
                                        items_to_remove.append(i)
                        
                        # 从后往前删除，避免索引变化
                        for i in reversed(items_to_remove):
                            try:
                                item = main_window.labelList.takeItem(i)
                                if item:
                                    del item
                            except:
                                pass
                        
                        if items_to_remove:
                            print(f"⚠ 已清理 {len(items_to_remove)} 个无效的 labelList item（shape 为 None）")
                    except Exception as e:
                        print(f"⚠ 清理 labelList 时出错: {e}")
                
                # 确保 shapes 列表不包含 None
                if hasattr(main_window, 'shapes'):
                    main_window.shapes = [s for s in main_window.shapes if s is not None]
                if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
                    main_window.canvas.shapes = [s for s in main_window.canvas.shapes if s is not None]
                
                # 先调用原始保存方法，确保 labelme 的内部状态正确更新
                # 这样 labelme 才能正确识别已保存状态，允许继续编辑和新增
                original_filename = None
                try:
                    # 临时设置 filename 为 annotation_file，让原始方法保存到正确位置
                    if hasattr(main_window, 'filename'):
                        original_filename = main_window.filename
                        main_window.filename = str(annotation_file)
                    
                    # 调用原始保存方法（这会更新 labelme 的内部状态，如 labelFile、dirty 等）
                    original_result = original_save_file(str(annotation_file))
                    
                    # 恢复原始 filename（指向图片文件）
                    if original_filename:
                        main_window.filename = original_filename
                    
                    if not original_result:
                        print(f"⚠ 原始保存方法返回失败")
                        print(f"[DEBUG] label_data中的shapes数量: {len(label_data.get('shapes', []))}")
                        # 如果原始方法失败，尝试手动保存
                        # 如果 label_data 中没有 shapes，重新获取并构建
                        if not label_data.get('shapes'):
                            print(f"⚠ 警告: label_data中没有shapes，重新从main_window获取")
                            # 重新获取shapes（使用之前获取的有效shapes）
                            if shapes:
                                # 重新构建label_data['shapes']
                                label_data['shapes'] = []
                                for shape in shapes:
                                    try:
                                        if hasattr(shape, 'toDict'):
                                            shape_dict = shape.toDict()
                                        else:
                                            shape_dict = {
                                                'label': getattr(shape, 'label', ''),
                                                'points': [],
                                                'group_id': getattr(shape, 'group_id', None),
                                                'shape_type': getattr(shape, 'shape_type', 'rectangle'),
                                                'flags': getattr(shape, 'flags', {}),
                                            }
                                            points = getattr(shape, 'points', [])
                                            for point in points:
                                                if hasattr(point, 'x') and hasattr(point, 'y'):
                                                    shape_dict['points'].append([float(point.x()), float(point.y())])
                                                elif isinstance(point, (list, tuple)) and len(point) >= 2:
                                                    shape_dict['points'].append([float(point[0]), float(point[1])])
                                        
                                        if shape_dict.get('label') and len(shape_dict.get('points', [])) >= 2:
                                            label_data['shapes'].append(shape_dict)
                                    except Exception as e:
                                        print(f"⚠ 重新转换shape时出错: {e}")
                                        continue
                                print(f"[DEBUG] 重新构建后label_data中的shapes数量: {len(label_data.get('shapes', []))}")
                        
                        try:
                            with open(annotation_file, 'w', encoding='utf-8') as f:
                                json.dump(label_data, f, ensure_ascii=False, indent=2)
                            # 标记为已保存
                            if hasattr(main_window, 'setDirty'):
                                try:
                                    import inspect
                                    sig = inspect.signature(main_window.setDirty)
                                    if len(sig.parameters) == 0:
                                        main_window.setDirty()
                                    else:
                                        main_window.setDirty(False)
                                except:
                                    try:
                                        main_window.setDirty()
                                    except:
                                        pass
                            original_result = True
                        except Exception as e:
                            print(f"⚠ 手动保存也失败: {e}")
                            return False
                    
                except Exception as e:
                    print(f"⚠ 调用原始保存方法失败: {e}")
                    import traceback
                    traceback.print_exc()
                    # 如果原始方法失败，尝试手动保存
                    # 如果 label_data 中没有 shapes，重新获取并构建
                    if not label_data.get('shapes') and shapes:
                        print(f"⚠ 警告: label_data中没有shapes，重新从shapes变量构建")
                        label_data['shapes'] = []
                        for shape in shapes:
                            try:
                                if hasattr(shape, 'toDict'):
                                    shape_dict = shape.toDict()
                                else:
                                    shape_dict = {
                                        'label': getattr(shape, 'label', ''),
                                        'points': [],
                                        'group_id': getattr(shape, 'group_id', None),
                                        'shape_type': getattr(shape, 'shape_type', 'rectangle'),
                                        'flags': getattr(shape, 'flags', {}),
                                    }
                                    points = getattr(shape, 'points', [])
                                    for point in points:
                                        if hasattr(point, 'x') and hasattr(point, 'y'):
                                            shape_dict['points'].append([float(point.x()), float(point.y())])
                                        elif isinstance(point, (list, tuple)) and len(point) >= 2:
                                            shape_dict['points'].append([float(point[0]), float(point[1])])
                                
                                if shape_dict.get('label') and len(shape_dict.get('points', [])) >= 2:
                                    label_data['shapes'].append(shape_dict)
                            except Exception as e:
                                print(f"⚠ 重新转换shape时出错: {e}")
                                continue
                        print(f"[DEBUG] 重新构建后label_data中的shapes数量: {len(label_data.get('shapes', []))}")
                    
                    try:
                        with open(annotation_file, 'w', encoding='utf-8') as f:
                            json.dump(label_data, f, ensure_ascii=False, indent=2)
                        # 标记为已保存
                        if hasattr(main_window, 'setDirty'):
                            try:
                                import inspect
                                sig = inspect.signature(main_window.setDirty)
                                if len(sig.parameters) == 0:
                                    main_window.setDirty()
                                else:
                                    main_window.setDirty(False)
                            except:
                                try:
                                    main_window.setDirty()
                                except:
                                    pass
                        original_result = True
                    except Exception as e2:
                        print(f"⚠ 手动保存也失败: {e2}")
                        return False
                
                # 移除 imageData 字段（如果原始方法添加了）
                try:
                    with open(annotation_file, 'r', encoding='utf-8') as f:
                        saved_data = json.load(f)
                    if 'imageData' in saved_data:
                        del saved_data['imageData']
                        with open(annotation_file, 'w', encoding='utf-8') as f:
                            json.dump(saved_data, f, ensure_ascii=False, indent=2)
                except Exception as e:
                    print(f"⚠ 移除 imageData 失败: {e}")
                
                print(f"✓ 标注已保存到单个 JSON: {annotation_file.name}")
                
                # 自动同步到 COCO 文件（确保训练时能使用最新标注）
                if coco_file:
                    try:
                        # 如果shapes为空，尝试从保存的JSON文件中读取
                        if not shapes:
                            print(f"[DEBUG] shapes为空，尝试从保存的JSON文件读取")
                            try:
                                with open(annotation_file, 'r', encoding='utf-8') as f:
                                    saved_json_data = json.load(f)
                                shapes_from_json = saved_json_data.get('shapes', [])
                                if shapes_from_json:
                                    # 使用字典格式的shapes（_update_coco_file支持字典格式）
                                    shapes = shapes_from_json
                                    print(f"[DEBUG] 从JSON文件读取到 {len(shapes)} 个shapes")
                            except Exception as e:
                                print(f"⚠ 从JSON文件读取shapes失败: {e}")
                        
                        # 确保 shapes 列表有效（支持Shape对象和字典格式）
                        valid_shapes_for_coco = []
                        for s in shapes:
                            if s is None:
                                continue
                            # 检查是否是有效的shape（Shape对象或字典）
                            if hasattr(s, 'label') and hasattr(s, 'points'):
                                # Shape对象
                                if s.label and len(s.points) >= 2:
                                    valid_shapes_for_coco.append(s)
                            elif isinstance(s, dict):
                                # 字典格式
                                if s.get('label') and len(s.get('points', [])) >= 2:
                                    valid_shapes_for_coco.append(s)
                        
                        if not valid_shapes_for_coco:
                            print(f"⚠ 警告: 没有有效的 shapes 可以同步到 COCO 文件（可能图片没有标注）")
                            print(f"[DEBUG] shapes总数: {len(shapes)}, 有效数: {len(valid_shapes_for_coco)}")
                        else:
                            print(f"[DEBUG] 准备同步 {len(valid_shapes_for_coco)} 个标注到 COCO 文件: {coco_file.name}")
                            
                            # 同步单个 JSON 到 COCO 文件
                            if _update_coco_file(coco_file, image_path, valid_shapes_for_coco, image_width, image_height):
                                print(f"✓ 已同步到 COCO 文件: {coco_file.name}")
                                
                                # 验证保存是否成功
                                if coco_file.exists():
                                    try:
                                        with open(coco_file, 'r', encoding='utf-8') as f:
                                            saved_coco = json.load(f)
                                        saved_images = len(saved_coco.get('images', []))
                                        saved_annotations = len(saved_coco.get('annotations', []))
                                        
                                        # 查找该图片的标注数量
                                        image_name_normalized = Path(image_path.name).name
                                        image_id = None
                                        for img in saved_coco.get('images', []):
                                            coco_file_name = Path(img.get('file_name', '')).name
                                            if coco_file_name == image_name_normalized:
                                                image_id = img.get('id')
                                                break
                                        
                                        if image_id:
                                            image_annotations = [
                                                ann for ann in saved_coco.get('annotations', [])
                                                if ann.get('image_id') == image_id
                                            ]
                                            print(f"[DEBUG] 验证: COCO 文件包含 {saved_images} 张图片, {saved_annotations} 个总标注")
                                            print(f"[DEBUG] 验证: 该图片 ({image_path.name}) 有 {len(image_annotations)} 个标注")
                                            
                                            if len(image_annotations) == len(valid_shapes_for_coco):
                                                print(f"✓ 标注数量验证通过: {len(image_annotations)} 个")
                                            else:
                                                print(f"⚠ 标注数量不匹配: COCO中有 {len(image_annotations)} 个, 期望 {len(valid_shapes_for_coco)} 个")
                                        else:
                                            print(f"⚠ 警告: 在COCO文件中未找到该图片")
                                    except Exception as e_verify:
                                        print(f"⚠ 验证 COCO 文件时出错: {e_verify}")
                                else:
                                    print(f"⚠ 警告: COCO 文件保存后不存在: {coco_file}")
                            else:
                                print(f"⚠ 同步到 COCO 文件失败: {coco_file.name}")
                    except Exception as e:
                        print(f"⚠ 同步到 COCO 文件时出错: {e}")
                        import traceback
                        traceback.print_exc()
                else:
                    print(f"   提示: 未找到 COCO 文件，标注仅保存在单个 JSON 中")
                
                return original_result
                
            except Exception as e:
                print(f"⚠ 保存标注文件失败: {e}")
                import traceback
                traceback.print_exc()
                # 如果保存到 annotations 失败，回退到原始保存方法
                return original_save_file(filename)
        else:
            # 没有找到 annotations 目录，使用原始保存方法（保存到图片同目录）
            return original_save_file(filename)
    
    # 替换 saveFile 方法
    main_window.saveFile = new_save_file


def _hook_file_scanning(main_window, dir_path: str):
    """
    Hook Labelme 的文件扫描方法，在扫描时直接排除 tmp 目录
    """
    try:
        # 尝试 Hook 文件扫描相关的方法
        # Labelme 可能会使用 os.listdir 或 Path.iterdir 来扫描文件
        # 我们可以 Hook 这些方法，但更直接的方法是 Hook 文件列表的更新
        
        # 方法1: Hook 文件列表的 populate 或 update 方法
        if hasattr(main_window, 'fileListWidget') and main_window.fileListWidget:
            file_list_widget = main_window.fileListWidget
            
            # 尝试 Hook populateFileList 或类似方法
            if hasattr(file_list_widget, 'populateFileList') and not hasattr(file_list_widget, '_original_populateFileList'):
                file_list_widget._original_populateFileList = file_list_widget.populateFileList
                
                def new_populateFileList(file_list):
                    """过滤掉 tmp 目录中的文件"""
                    filtered_list = []
                    for file_path in file_list:
                        file_path_str = str(file_path)
                        file_path_lower = file_path_str.lower()
                        
                        # 排除 tmp 目录中的文件
                        if '/tmp/' in file_path_lower or '\\tmp\\' in file_path_lower:
                            print(f"[Hook扫描] 排除 tmp 目录中的文件: {file_path_str}")
                            continue
                        
                        # 排除 Mac ._ 文件
                        file_name = Path(file_path_str).name
                        if file_name.startswith('._'):
                            print(f"[Hook扫描] 排除 Mac 资源分叉文件: {file_name}")
                            continue
                        
                        # 检查路径部分
                        try:
                            file_path_obj = Path(file_path_str)
                            if 'tmp' in file_path_obj.parts:
                                tmp_index = file_path_obj.parts.index('tmp')
                                if tmp_index < len(file_path_obj.parts) - 1:
                                    print(f"[Hook扫描] 排除 tmp 目录中的文件: {file_path_str}")
                                    continue
                        except:
                            pass
                        
                        filtered_list.append(file_path)
                    
                    return file_list_widget._original_populateFileList(filtered_list)
                
                file_list_widget.populateFileList = new_populateFileList
                print("[Hook扫描] ✓ 已 Hook populateFileList 方法")
        
        # 方法2: 直接修改 dir_path，排除 tmp 子目录
        # 但这可能不适用，因为 Labelme 可能会递归扫描
        
    except Exception as e:
        print(f"⚠ Hook 文件扫描方法失败: {e}")
        import traceback
        traceback.print_exc()


def _hook_file_list_update(main_window):
    """
    Hook Labelme 的文件列表更新方法，在扫描文件时直接排除 tmp 目录
    """
    try:
        # 尝试 Hook 文件列表的更新方法
        if hasattr(main_window, 'fileListWidget') and main_window.fileListWidget:
            file_list_widget = main_window.fileListWidget
            # Hook addItem 方法
            if hasattr(file_list_widget, 'addItem') and not hasattr(file_list_widget, '_original_addItem'):
                file_list_widget._original_addItem = file_list_widget.addItem
                
                def new_addItem(item):
                    """过滤掉 tmp 目录中的文件和 Mac ._ 文件"""
                    print(f"[Hook addItem] 被调用，item 类型: {type(item)}")
                    # 获取文件路径
                    file_path_str = None
                    if hasattr(item, 'text'):
                        file_path_str = item.text()
                        print(f"[Hook addItem] 通过 text() 获取: {file_path_str}")
                    elif isinstance(item, str):
                        file_path_str = item
                        print(f"[Hook addItem] 直接使用字符串: {file_path_str}")
                    else:
                        print(f"[Hook addItem] ⚠ 无法获取文件路径，item 属性: {dir(item)}")
                    
                    if file_path_str:
                        try:
                            file_path = Path(file_path_str)
                            file_name = file_path.name
                            print(f"[Hook addItem] 文件路径: {file_path_str}, 文件名: {file_name}")
                            
                            # 排除 Mac ._ 文件
                            if file_name.startswith('._'):
                                print(f"[Hook addItem] ✓ 排除 Mac 资源分叉文件: {file_name}")
                                return
                            
                            # 调试：检查中文文件名
                            if any(ord(c) > 127 for c in file_name):
                                print(f"[Hook addItem] 🔍 检测到中文文件名: {file_name} (编码: {file_name.encode('utf-8')})")
                            
                            # 排除 tmp 目录中的文件
                            try:
                                # 获取绝对路径
                                if not file_path.is_absolute():
                                    # 尝试解析相对路径
                                    try:
                                        file_path_abs = file_path.resolve()
                                    except:
                                        file_path_abs = file_path
                                else:
                                    file_path_abs = file_path.resolve()
                                
                                path_str = str(file_path_abs).lower()
                                path_parts = list(file_path_abs.parts) if hasattr(file_path_abs, 'parts') else path_str.replace('\\', '/').split('/')
                                
                                print(f"[Hook addItem] 绝对路径: {file_path_abs}, 路径部分: {path_parts}")
                                
                                # 检查是否在 tmp 目录中
                                if 'tmp' in path_parts:
                                    tmp_index = path_parts.index('tmp')
                                    print(f"[Hook addItem] 找到 tmp 在索引 {tmp_index}, 总长度 {len(path_parts)}")
                                    # 如果 tmp 后面还有路径部分，或者 tmp 是倒数第二个（最后一个是文件名），说明在 tmp 目录中
                                    if tmp_index < len(path_parts) - 1:
                                        print(f"[Hook addItem] ✓ 排除 tmp 目录中的文件: {file_path_abs}")
                                        return
                                
                                # 也检查路径字符串
                                if '/tmp/' in path_str or '\\tmp\\' in path_str:
                                    print(f"[Hook addItem] ✓ 排除 tmp 目录中的文件（字符串匹配）: {file_path_abs}")
                                    return
                            except Exception as e:
                                print(f"[Hook addItem] ⚠ 解析路径失败: {e}")
                                import traceback
                                traceback.print_exc()
                                # 如果解析失败，也检查原始路径字符串
                                path_str_lower = str(file_path_str).lower()
                                if '/tmp/' in path_str_lower or '\\tmp\\' in path_str_lower:
                                    print(f"[Hook addItem] ✓ 排除 tmp 目录中的文件（原始字符串检查）: {file_path_str}")
                                    return
                        except Exception as e:
                            print(f"[Hook addItem] ⚠ 处理文件路径时出错: {e}")
                            import traceback
                            traceback.print_exc()
                    else:
                        print(f"[Hook addItem] ⚠ 文件路径为空，直接添加")
                    
                    # 如果不是 tmp 文件，正常添加
                    print(f"[Hook addItem] ✓ 添加文件: {file_path_str if file_path_str else 'N/A'}")
                    return file_list_widget._original_addItem(item)
                
                file_list_widget.addItem = new_addItem
                print("[Hook] ✓ 已 Hook 文件列表 addItem 方法，将自动排除 tmp 目录文件")
        
        # 也尝试 Hook 其他可能的文件列表更新方法
        if hasattr(main_window, 'fileList') and main_window.fileList:
            file_list = main_window.fileList
            if hasattr(file_list, 'append') and not hasattr(file_list, '_original_append'):
                file_list._original_append = file_list.append
                
                def new_append(item):
                    """过滤掉 tmp 目录中的文件"""
                    if isinstance(item, (str, Path)):
                        file_path = Path(item)
                        path_str = str(file_path).lower()
                        if '/tmp/' in path_str or '\\tmp\\' in path_str or 'tmp' in file_path.parts:
                            print(f"[Hook] 排除 tmp 目录中的文件: {file_path}")
                            return
                    return file_list._original_append(item)
                
                file_list.append = new_append
                print("[Hook] ✓ 已 Hook 文件列表 append 方法")
                
    except Exception as e:
        print(f"⚠ Hook 文件列表更新方法失败: {e}")
        import traceback
        traceback.print_exc()


def _filter_file_list(main_window, dir_path: str):
    """
    过滤文件列表，排除 Mac 的 ._ 文件和 tmp 目录中的文件
    
    Args:
        main_window: Labelme 主窗口实例
        dir_path: 打开的目录路径
    """
    try:
        # 获取目录路径对象
        dir_path_obj = Path(dir_path)
        
        # 需要移除的文件项
        items_to_remove = []
        
        # 方法1: 尝试通过 fileListWidget 访问文件列表
        file_list_widget = None
        if hasattr(main_window, 'fileListWidget'):
            file_list_widget = main_window.fileListWidget
        
        # 方法2: 尝试通过 fileList 访问文件列表
        if not file_list_widget and hasattr(main_window, 'fileList'):
            file_list_widget = main_window.fileList
        
        # 方法3: 尝试通过 mw 访问（某些版本的 Labelme）
        if not file_list_widget and hasattr(main_window, 'mw') and hasattr(main_window.mw, 'fileListWidget'):
            file_list_widget = main_window.mw.fileListWidget
        
        if not file_list_widget:
            # 如果没有文件列表组件，尝试直接扫描目录并过滤
            # 这可以作为备用方案，但通常 Labelme 会有文件列表组件
            return
    
        # 遍历文件列表项
        item_count = 0
        if hasattr(file_list_widget, 'count'):
            item_count = file_list_widget.count()
        elif hasattr(file_list_widget, '__len__'):
            item_count = len(file_list_widget)
        
        print(f"[过滤] 开始过滤文件列表，共 {item_count} 个文件项")
        
        for i in range(item_count):
            try:
                # 获取列表项
                if hasattr(file_list_widget, 'item'):
                    item = file_list_widget.item(i)
                elif hasattr(file_list_widget, '__getitem__'):
                    item = file_list_widget[i]
                else:
                    continue
                
                if not item:
                    continue
                
                # 获取文件路径（尝试多种方式）
                file_path_str = None
                if hasattr(item, 'text'):
                    file_path_str = item.text()
                    print(f"[过滤] 文件项 {i}: 通过 text() 获取路径: {file_path_str}")
                elif hasattr(item, 'data'):
                    try:
                        file_path_str = str(item.data(Qt.DisplayRole))
                        print(f"[过滤] 文件项 {i}: 通过 data(DisplayRole) 获取路径: {file_path_str}")
                    except:
                        pass
                elif isinstance(item, str):
                    file_path_str = item
                    print(f"[过滤] 文件项 {i}: 直接使用字符串路径: {file_path_str}")
                
                # 如果还没有获取到，尝试其他方式
                if not file_path_str:
                    # 尝试获取 toolTip（有些 Labelme 版本在 toolTip 中存储完整路径）
                    if hasattr(item, 'toolTip'):
                        file_path_str = item.toolTip()
                        print(f"[过滤] 文件项 {i}: 通过 toolTip() 获取路径: {file_path_str}")
                    # 尝试获取 data(Qt.UserRole) 或其他角色
                    elif hasattr(item, 'data'):
                        try:
                            from PyQt5.QtCore import Qt
                            file_path_str = str(item.data(Qt.UserRole))
                            print(f"[过滤] 文件项 {i}: 通过 data(UserRole) 获取路径: {file_path_str}")
                        except:
                            pass
                
                if not file_path_str:
                    print(f"[过滤] 文件项 {i}: ⚠ 无法获取文件路径，跳过")
                    continue
                
                file_path = Path(file_path_str)
                file_name = file_path.name
                print(f"[过滤] 文件项 {i}: 文件名={file_name}, 原始路径={file_path_str}")
                
                # 调试：检查中文文件名
                if any(ord(c) > 127 for c in file_name):
                    print(f"[过滤] 🔍 检测到中文文件名: {file_name} (编码: {file_name.encode('utf-8')})")
                
                # 检查是否是 Mac 的 ._ 文件
                if file_name.startswith('._'):
                    items_to_remove.append(i)
                    print(f"[过滤] 排除 Mac 资源分叉文件: {file_name}")
                    continue
                
                # 检查是否在 tmp 目录中
                try:
                    # 获取文件的绝对路径
                    if not file_path.is_absolute():
                        # 如果是相对路径，相对于打开的目录
                        file_path = dir_path_obj / file_path
                        print(f"[过滤] 文件项 {i}: 相对路径，转换为: {file_path}")
                    
                    file_path_abs = file_path.resolve()
                    file_path_str_abs = str(file_path_abs)
                    print(f"[过滤] 文件项 {i}: 绝对路径={file_path_abs}")
                    
                    # 检查路径中是否包含 tmp（更严格的检查）
                    path_parts = file_path_abs.parts
                    path_str_lower = file_path_str_abs.lower()
                    print(f"[过滤] 文件项 {i}: 路径部分={path_parts}, 路径字符串(小写)={path_str_lower}")
                    
                    # 检查是否在 tmp 目录中（更严格的匹配）
                    is_in_tmp = False
                    if 'tmp' in path_parts:
                        print(f"[过滤] 文件项 {i}: 路径部分中包含 'tmp'")
                        # 检查 tmp 是否作为目录名出现（不是文件名的一部分）
                        try:
                            tmp_index = path_parts.index('tmp')
                            print(f"[过滤] 文件项 {i}: tmp 在路径部分的索引={tmp_index}, 总长度={len(path_parts)}")
                            # 如果 tmp 后面还有路径部分，说明是目录
                            if tmp_index < len(path_parts) - 1:
                                is_in_tmp = True
                                print(f"[过滤] 文件项 {i}: ✓ tmp 后面还有路径，判定为 tmp 目录")
                            # 或者如果 tmp 是最后一个目录部分（倒数第二个是 tmp，最后一个是文件名）
                            elif tmp_index == len(path_parts) - 2:  # tmp 是倒数第二个，最后一个是文件名
                                is_in_tmp = True
                                print(f"[过滤] 文件项 {i}: ✓ tmp 是倒数第二个目录，判定为 tmp 目录")
                        except ValueError:
                            print(f"[过滤] 文件项 {i}: ⚠ 在路径部分中找不到 'tmp'")
                    
                    # 也检查路径字符串中是否包含 /tmp/ 或 \tmp\
                    if not is_in_tmp:
                        if '/tmp/' in path_str_lower or '\\tmp\\' in path_str_lower:
                            is_in_tmp = True
                            print(f"[过滤] 文件项 {i}: ✓ 路径字符串中包含 /tmp/ 或 \\tmp\\，判定为 tmp 目录")
                        # 检查是否以 /tmp 或 \tmp 结尾（但后面没有其他路径）
                        elif path_str_lower.endswith('/tmp') or path_str_lower.endswith('\\tmp'):
                            is_in_tmp = True
                            print(f"[过滤] 文件项 {i}: ✓ 路径字符串以 /tmp 或 \\tmp 结尾，判定为 tmp 目录")
                    
                    if is_in_tmp:
                        items_to_remove.append(i)
                        print(f"[过滤] ✓ 文件项 {i}: 标记为删除 - tmp 目录中的文件: {file_path_abs}")
                    else:
                        print(f"[过滤] 文件项 {i}: 不在 tmp 目录中，保留")
                    continue
                except Exception as e:
                    # 如果路径解析失败，跳过
                    print(f"[过滤] ⚠ 文件项 {i}: 解析文件路径失败 {file_path_str}: {e}")
                    import traceback
                    traceback.print_exc()
                    continue
            except Exception as e:
                # 如果获取项失败，跳过
                continue
        
        # 从后往前删除（避免索引变化）
        print(f"[过滤] 准备删除 {len(items_to_remove)} 个文件项: {items_to_remove}")
        removed_count = 0
        for i in reversed(items_to_remove):
            try:
                print(f"[过滤] 尝试删除文件项 {i}")
                if hasattr(file_list_widget, 'takeItem'):
                    removed_item = file_list_widget.takeItem(i)
                    if removed_item:
                        print(f"[过滤] ✓ 成功删除文件项 {i}: {removed_item.text() if hasattr(removed_item, 'text') else 'N/A'}")
                        del removed_item
                        removed_count += 1
                    else:
                        print(f"[过滤] ⚠ 文件项 {i} 删除返回 None")
                elif hasattr(file_list_widget, 'removeItem'):
                    file_list_widget.removeItem(i)
                    print(f"[过滤] ✓ 成功删除文件项 {i} (使用 removeItem)")
                    removed_count += 1
                elif hasattr(file_list_widget, '__delitem__'):
                    del file_list_widget[i]
                    print(f"[过滤] ✓ 成功删除文件项 {i} (使用 __delitem__)")
                    removed_count += 1
                else:
                    print(f"[过滤] ⚠ 文件列表组件不支持删除操作")
            except Exception as e:
                print(f"[过滤] ⚠ 移除文件列表项失败 (索引 {i}): {e}")
                import traceback
                traceback.print_exc()
        
        if removed_count > 0:
            print(f"[过滤] ✓✓✓ 已过滤 {removed_count} 个文件（Mac ._ 文件和 tmp 目录文件）")
        else:
            print(f"[过滤] ⚠ 没有删除任何文件项")
    
    except Exception as e:
        print(f"⚠ 过滤文件列表失败: {e}")
        import traceback
        traceback.print_exc()


def _load_categories_from_coco(main_window):
    """
    从 COCO 文件加载类别列表到 labelList
    
    类别来源：
    1. 优先从 instance_train.json 和 instance_val.json 的 categories 字段读取
    2. 如果 COCO 文件不存在，使用标准类别列表作为后备
    
    标准类别列表（从 continue_gen_dataset.py 和 COCO 文件定义）：
    - Text, Title, Figure, Figure caption, Table, Table caption, Header, Footer, Seal, Equation, Handwriting
    """
    try:
        # 获取项目根目录
        current_dir = Path(__file__).parent.parent.parent
        project_root = current_dir
        
        # 查找 annotations 目录
        dataset_dir = project_root / "paddleocr" / "dataset"
        annotations_dir = dataset_dir / "annotations"
        
        categories = []
        
        # 优先从 COCO 文件加载
        if annotations_dir.exists():
            try:
                from coco_to_labelme_json import get_all_coco_categories
                categories = get_all_coco_categories(annotations_dir)
                if categories:
                    print(f"✓ 从 COCO 文件加载了 {len(categories)} 个类别")
            except ImportError as e:
                print(f"⚠ 无法导入 coco_to_labelme_json 模块: {e}")
            except Exception as e:
                print(f"⚠ 从 COCO 文件加载类别失败: {e}")
        
        # 标准类别列表（完整的12个类别，包括 _background_）
        standard_categories = [
            "_background_", "Text", "Title", "Figure", "Figure caption", 
            "Table", "Table caption", "Header", "Footer", 
            "Seal", "Equation", "Handwriting"
        ]
        
        # 如果 COCO 文件中没有类别，使用标准类别列表作为后备
        if not categories:
            categories = standard_categories
            print(f"⚠ COCO 文件中没有找到类别，使用标准类别列表: {', '.join(categories)}")
        else:
            # 如果 COCO 文件中有类别，但缺少标准类别，补充缺失的类别
            missing_categories = set(standard_categories) - set(categories)
            if missing_categories:
                print(f"⚠ COCO 文件中缺少 {len(missing_categories)} 个标准类别，将补充: {', '.join(sorted(missing_categories))}")
                categories.extend(missing_categories)
                categories = sorted(categories)  # 重新排序
        
        # 过滤掉 _background_ 类别（背景类别不应该在 labelList 中显示）
        categories = [cat for cat in categories if cat != "_background_"]
        
        if not categories:
            print(f"⚠ 没有可用的类别，跳过加载")
            return
        
        # 添加到 labelList
        added_count = 0
        failed_count = 0
        failed_categories = []
        
        if hasattr(main_window, 'labelList') and main_window.labelList:
            # 获取现有标签列表
            existing_labels = []
            try:
                if hasattr(main_window.labelList, 'count'):
                    for i in range(main_window.labelList.count()):
                        item = main_window.labelList.item(i)
                        if item:
                            existing_labels.append(item.text())
            except Exception as e:
                print(f"⚠ 获取现有标签列表失败: {e}")
            
            # 尝试使用 labelme 的标准方法添加标签（如果存在）
            use_standard_method = False
            if hasattr(main_window, 'loadLabels'):
                try:
                    main_window.loadLabels(categories)
                    use_standard_method = True
                    added_count = len(categories)
                    print(f"✓ 使用 loadLabels 方法添加了 {added_count} 个类别")
                except Exception as e:
                    print(f"⚠ loadLabels 方法失败: {e}, 使用 addItem 方法")
            
            # 如果标准方法失败，逐个添加
            if not use_standard_method:
                for cat in categories:
                    if not cat or not isinstance(cat, str):
                        continue
                    
                    # 检查是否已存在
                    if cat in existing_labels:
                        continue
                    
                    # 使用辅助函数添加
                    if _add_label_to_list(main_window, cat):
                        added_count += 1
                        existing_labels.append(cat)  # 更新已存在列表，避免重复
                    else:
                        failed_count += 1
                        failed_categories.append(cat)
        
        # 输出结果
        if added_count > 0:
            print(f"✓ 成功添加 {added_count}/{len(categories)} 个类别到 Label List")
            if failed_count > 0:
                print(f"⚠ 有 {failed_count} 个类别添加失败: {', '.join(failed_categories)}")
        else:
            print(f"⚠ 未能添加任何类别到 Label List（共 {len(categories)} 个类别）")
            print(f"   类别列表: {', '.join(categories)}")
    except Exception as e:
        print(f"⚠ 加载类别列表失败: {e}")
        import traceback
        traceback.print_exc()


def _sync_label_list_with_canvas(main_window, preserve_dirty_state=True):
    """同步 labelList 和 canvas.shapes，移除无效的 item，并确保 canvas.shapes 与 labelList 一致
    
    Args:
        main_window: Labelme 主窗口实例
        preserve_dirty_state: 如果为 True，同步操作不会改变文件的 dirty 状态（避免触发保存提示）
    """
    try:
        # 记录当前的 dirty 状态
        was_dirty = False
        if preserve_dirty_state:
            if hasattr(main_window, 'isDirty'):
                was_dirty = main_window.isDirty()
            elif hasattr(main_window, 'windowModified'):
                was_dirty = main_window.windowModified()
        
        if hasattr(main_window, 'labelList') and main_window.labelList:
            if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'shapes'):
                # 从 labelList 收集所有有效的 shapes
                valid_shapes_from_label_list = []
                if hasattr(main_window.labelList, 'count'):
                    for i in range(main_window.labelList.count()):
                        item = main_window.labelList.item(i)
                        if item:
                            try:
                                if hasattr(item, 'shape'):
                                    shape = item.shape()
                                    if shape is not None:
                                        valid_shapes_from_label_list.append(shape)
                            except Exception:
                                pass
                
                # 获取 canvas 中当前的 shapes
                canvas_shapes = main_window.canvas.shapes if hasattr(main_window.canvas, 'shapes') else []
                valid_canvas_shapes = [s for s in canvas_shapes if s is not None]
                
                # 清理 labelList 中不在 canvas.shapes 中的 item（或者 shape 为 None 的 item）
                items_to_remove = []
                if hasattr(main_window.labelList, 'count'):
                    for i in range(main_window.labelList.count() - 1, -1, -1):
                        item = main_window.labelList.item(i)
                        if item:
                            try:
                                if hasattr(item, 'shape'):
                                    item_shape = item.shape()
                                    # 检查这个 shape 是否为 None 或不在有效的 canvas shapes 中
                                    if item_shape is None or item_shape not in valid_canvas_shapes:
                                        items_to_remove.append(i)
                            except Exception:
                                # 如果获取 shape 失败，也移除
                                items_to_remove.append(i)
                
                # 从后往前删除
                for i in items_to_remove:
                    try:
                        removed_item = main_window.labelList.takeItem(i)
                        if removed_item:
                            del removed_item
                    except Exception:
                        pass
                
                # 更新 canvas.shapes，但只在 labelList 中的 shape 数量少于 canvas.shapes 时才更新
                # 这样可以避免在删除操作时恢复已删除的 shape
                # 如果 labelList 中的 shape 数量更多，说明可能是新添加的，不更新 canvas.shapes
                if len(valid_shapes_from_label_list) < len(valid_canvas_shapes):
                    # labelList 中的 shape 数量更少，说明有删除操作，更新 canvas.shapes
                    main_window.canvas.shapes = valid_shapes_from_label_list
                    print(f"[DEBUG] 同步 canvas.shapes: 从 {len(valid_canvas_shapes)} 个更新为 {len(valid_shapes_from_label_list)} 个（删除操作）")
                elif len(valid_shapes_from_label_list) == len(valid_canvas_shapes):
                    # 数量相同，检查是否有不一致的 shape
                    if not all(s in valid_canvas_shapes for s in valid_shapes_from_label_list):
                        # 有 shape 不一致，更新 canvas.shapes
                        main_window.canvas.shapes = valid_shapes_from_label_list
                        print(f"[DEBUG] 同步 canvas.shapes: 更新了不一致的 shapes")
                # 如果 labelList 中的 shape 数量更多，不更新 canvas.shapes（可能是新添加的，让 labelme 自己处理）
                
                if items_to_remove:
                    print(f"✓ 已同步 labelList，移除了 {len(items_to_remove)} 个无效的 item")
        
        # 如果之前不是 dirty 状态，且 preserve_dirty_state 为 True，恢复为未修改状态
        # 这样可以避免同步操作触发不必要的保存提示
        if preserve_dirty_state and not was_dirty:
            try:
                if hasattr(main_window, 'setDirty'):
                    import inspect
                    sig = inspect.signature(main_window.setDirty)
                    if len(sig.parameters) > 0:
                        main_window.setDirty(False)
                elif hasattr(main_window, 'setWindowModified'):
                    main_window.setWindowModified(False)
            except Exception:
                pass
                
    except Exception as e:
        print(f"⚠ 同步 labelList 时出错: {e}")
        import traceback
        traceback.print_exc()


def _hook_shape_selection_changed(main_window):
    """
    拦截 Labelme 的 shapeSelectionChanged 方法，防止访问 None shape
    
    Args:
        main_window: Labelme 主窗口实例
    """
    if not hasattr(main_window, 'shapeSelectionChanged'):
        return
    
    # 保存原始方法
    original_shape_selection_changed = main_window.shapeSelectionChanged
    
    def new_shape_selection_changed():
        """增强的 shapeSelectionChanged 方法，防止访问 None shape"""
        try:
            # 在调用原始方法前，只清理 labelList 中的 None shape item
            # 不要立即同步，避免干扰删除操作
            if hasattr(main_window, 'labelList') and main_window.labelList:
                try:
                    items_to_remove = []
                    if hasattr(main_window.labelList, 'count'):
                        for i in range(main_window.labelList.count() - 1, -1, -1):
                            item = main_window.labelList.item(i)
                            if item:
                                try:
                                    # 检查 item 的 shape 是否为 None
                                    if hasattr(item, 'shape'):
                                        shape = item.shape()
                                        if shape is None:
                                            items_to_remove.append(i)
                                except Exception:
                                    # 如果获取 shape 失败，也移除
                                    items_to_remove.append(i)
                    
                    # 从后往前删除，避免索引变化
                    for i in items_to_remove:
                        try:
                            removed_item = main_window.labelList.takeItem(i)
                            if removed_item:
                                del removed_item
                        except Exception:
                            pass
                    
                    if items_to_remove:
                        print(f"[DEBUG] shapeSelectionChanged: 清理了 {len(items_to_remove)} 个 None shape item")
                except Exception as e:
                    print(f"⚠ 清理 None shape item 时出错: {e}")
            
            # 调用原始方法（使用try-except包裹，防止崩溃）
            try:
                original_shape_selection_changed()
            except (AttributeError, ValueError) as inner_e:
                # 如果原始方法抛出错误，在这里处理
                error_str = str(inner_e)
                if "cannot find shape" in error_str or "'NoneType' object has no attribute 'selected'" in error_str:
                    print(f"⚠ 原始 shapeSelectionChanged 方法出错: {error_str}，已自动处理")
                    # 只有在出错时才同步 labelList 和 canvas.shapes
                    _sync_label_list_with_canvas(main_window, preserve_dirty_state=True)
                    # 不再抛出异常，避免崩溃
                    return
                else:
                    # 其他错误，重新抛出到外层处理
                    raise
            
        except (AttributeError, ValueError) as e:
            # 捕获 AttributeError 和 ValueError（包括 "cannot find shape" 错误）
            error_str = str(e)
            if "'NoneType' object has no attribute 'selected'" in error_str or "cannot find shape" in error_str:
                print(f"⚠ 拦截到 shape 访问错误: {error_str}，已自动处理")
                # 同步 labelList 和 canvas.shapes（保持 dirty 状态）
                _sync_label_list_with_canvas(main_window, preserve_dirty_state=True)
            else:
                # 其他错误，记录但不抛出（避免崩溃）
                print(f"⚠ shapeSelectionChanged 发生错误: {e}")
                import traceback
                traceback.print_exc()
        except Exception as e:
            # 捕获所有其他异常，避免崩溃
            print(f"⚠ shapeSelectionChanged 发生未预期的错误: {e}")
            import traceback
            traceback.print_exc()
    
    # 替换方法
    main_window.shapeSelectionChanged = new_shape_selection_changed


def _hook_canvas_load_shapes(main_window):
    """
    拦截 Canvas 的 loadShapes 方法，过滤掉 None 的 shape
    
    Args:
        main_window: Labelme 主窗口实例
    """
    if not hasattr(main_window, 'canvas') or not hasattr(main_window.canvas, 'loadShapes'):
        return
    
    # 保存原始方法
    if not hasattr(main_window.canvas, '_original_load_shapes'):
        main_window.canvas._original_load_shapes = main_window.canvas.loadShapes
    
    def new_load_shapes(shapes, replace=False):
        """增强的 loadShapes 方法，过滤掉 None 的 shape"""
        try:
            # 过滤掉 None 的 shape
            if shapes:
                valid_shapes = [s for s in shapes if s is not None]
                if len(valid_shapes) < len(shapes):
                    print(f"[DEBUG] loadShapes: 过滤掉了 {len(shapes) - len(valid_shapes)} 个 None shape")
                    shapes = valid_shapes
            
            # 调用原始方法
            result = main_window.canvas._original_load_shapes(shapes, replace=replace)
            
            # 调用后确保 canvas 刷新（特别是删除操作后）
            # 注意：不要在这里触发保存提示，因为这只是同步操作
            if hasattr(main_window.canvas, 'update'):
                main_window.canvas.update()
            if hasattr(main_window.canvas, 'repaint'):
                main_window.canvas.repaint()
            
            return result
        except AttributeError as e:
            error_str = str(e)
            if "'NoneType' object has no attribute 'copy'" in error_str:
                print(f"⚠ 拦截到 None shape 错误: {error_str}，已自动处理")
                # 再次过滤并重试
                if shapes:
                    valid_shapes = [s for s in shapes if s is not None]
                    if valid_shapes:
                        try:
                            return main_window.canvas._original_load_shapes(valid_shapes, replace=replace)
                        except Exception as e2:
                            print(f"⚠ 重试 loadShapes 失败: {e2}")
                return None
            else:
                raise
        except Exception as e:
            print(f"⚠ loadShapes 发生错误: {e}")
            import traceback
            traceback.print_exc()
            raise
    
    # 替换方法
    main_window.canvas.loadShapes = new_load_shapes


def _hook_label_order_changed(main_window):
    """
    拦截 Labelme 的 labelOrderChanged 方法，防止访问 None shape
    
    Args:
        main_window: Labelme 主窗口实例
    """
    if not hasattr(main_window, 'labelOrderChanged'):
        return
    
    # 保存原始方法
    original_label_order_changed = main_window.labelOrderChanged
    
    def new_label_order_changed():
        """增强的 labelOrderChanged 方法，过滤掉 None 的 shape"""
        try:
            # 在调用原始方法前，先清理 labelList 中的 None shape item
            if hasattr(main_window, 'labelList') and main_window.labelList:
                try:
                    items_to_remove = []
                    if hasattr(main_window.labelList, 'count'):
                        for i in range(main_window.labelList.count() - 1, -1, -1):
                            item = main_window.labelList.item(i)
                            if item:
                                try:
                                    # 检查 item 的 shape 是否为 None
                                    if hasattr(item, 'shape'):
                                        shape = item.shape()
                                        if shape is None:
                                            items_to_remove.append(i)
                                except Exception:
                                    # 如果获取 shape 失败，也移除
                                    items_to_remove.append(i)
                    
                    # 从后往前删除，避免索引变化
                    for i in items_to_remove:
                        try:
                            removed_item = main_window.labelList.takeItem(i)
                            if removed_item:
                                del removed_item
                        except Exception:
                            pass
                    
                    if items_to_remove:
                        print(f"[DEBUG] labelOrderChanged: 清理了 {len(items_to_remove)} 个 None shape item")
                except Exception as e:
                    print(f"⚠ 清理 None shape item 时出错: {e}")
            
            # 调用原始方法，但需要确保它不会因为 None shape 而崩溃
            # 原始方法会调用 canvas.loadShapes([item.shape() for item in self.labelList])
            # 我们需要拦截这个调用，过滤掉 None 的 shape
            try:
                # 如果原始方法会直接调用 canvas.loadShapes，我们需要先准备有效的 shapes
                if hasattr(main_window, 'labelList') and main_window.labelList:
                    # 收集所有有效的 shapes
                    valid_shapes = []
                    if hasattr(main_window.labelList, 'count'):
                        for i in range(main_window.labelList.count()):
                            item = main_window.labelList.item(i)
                            if item:
                                try:
                                    if hasattr(item, 'shape'):
                                        shape = item.shape()
                                        if shape is not None:
                                            valid_shapes.append(shape)
                                except Exception:
                                    pass
                    
                    # 如果收集到的 shapes 数量与 labelList 中的 items 数量不一致，
                    # 说明有 None shape，我们需要先清理，然后手动调用 loadShapes
                    if len(valid_shapes) < main_window.labelList.count():
                        print(f"[DEBUG] labelOrderChanged: 检测到 {main_window.labelList.count() - len(valid_shapes)} 个 None shape，先清理")
                        # 清理 None shape items
                        items_to_remove = []
                        if hasattr(main_window.labelList, 'count'):
                            for i in range(main_window.labelList.count() - 1, -1, -1):
                                item = main_window.labelList.item(i)
                                if item:
                                    try:
                                        if hasattr(item, 'shape'):
                                            shape = item.shape()
                                            if shape is None:
                                                items_to_remove.append(i)
                                    except Exception:
                                        items_to_remove.append(i)
                        
                        for i in items_to_remove:
                            try:
                                removed_item = main_window.labelList.takeItem(i)
                                if removed_item:
                                    del removed_item
                            except Exception:
                                pass
                        
                        # 重新收集有效的 shapes
                        valid_shapes = []
                        if hasattr(main_window.labelList, 'count'):
                            for i in range(main_window.labelList.count()):
                                item = main_window.labelList.item(i)
                                if item:
                                    try:
                                        if hasattr(item, 'shape'):
                                            shape = item.shape()
                                            if shape is not None:
                                                valid_shapes.append(shape)
                                    except Exception:
                                        pass
                        
                        # 手动调用 loadShapes，使用有效的 shapes
                        if hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'loadShapes'):
                            try:
                                main_window.canvas.loadShapes(valid_shapes)
                                print(f"[DEBUG] labelOrderChanged: 手动调用 loadShapes，加载了 {len(valid_shapes)} 个有效 shapes")
                                # 确保 canvas 刷新
                                if hasattr(main_window.canvas, 'update'):
                                    main_window.canvas.update()
                                if hasattr(main_window.canvas, 'repaint'):
                                    main_window.canvas.repaint()
                                return
                            except Exception as e:
                                print(f"⚠ 手动调用 loadShapes 失败: {e}")
                                import traceback
                                traceback.print_exc()
                
                # 如果一切正常，调用原始方法
                original_label_order_changed()
                
                # 调用原始方法后，确保 canvas 和 labelList 同步
                # 这很重要，因为删除操作后需要刷新显示
                # 注意：同步操作不应该触发保存提示
                try:
                    # 记录当前的 dirty 状态
                    was_dirty = False
                    if hasattr(main_window, 'isDirty'):
                        was_dirty = main_window.isDirty()
                    elif hasattr(main_window, 'windowModified'):
                        was_dirty = main_window.windowModified()
                    
                    # 同步 labelList 和 canvas.shapes
                    _sync_label_list_with_canvas(main_window)
                    
                    # 确保 canvas 刷新
                    if hasattr(main_window, 'canvas'):
                        if hasattr(main_window.canvas, 'update'):
                            main_window.canvas.update()
                        if hasattr(main_window.canvas, 'repaint'):
                            main_window.canvas.repaint()
                    
                    # 如果之前不是 dirty 状态，且只是同步操作（没有实际修改），恢复为未修改状态
                    # 这样可以避免同步操作触发不必要的保存提示
                    if not was_dirty:
                        try:
                            if hasattr(main_window, 'setDirty'):
                                import inspect
                                sig = inspect.signature(main_window.setDirty)
                                if len(sig.parameters) > 0:
                                    main_window.setDirty(False)
                                else:
                                    # 如果没有参数，可能需要其他方式
                                    pass
                            elif hasattr(main_window, 'setWindowModified'):
                                main_window.setWindowModified(False)
                        except Exception:
                            pass
                except Exception as e:
                    print(f"⚠ 刷新 canvas 时出错: {e}")
                
            except AttributeError as e:
                error_str = str(e)
                if "'NoneType' object has no attribute 'copy'" in error_str:
                    print(f"⚠ 拦截到 None shape 错误: {error_str}，已自动处理")
                    # 同步 labelList 和 canvas.shapes
                    _sync_label_list_with_canvas(main_window)
                    # 尝试重新加载有效的 shapes
                    if hasattr(main_window, 'labelList') and main_window.labelList:
                        valid_shapes = []
                        if hasattr(main_window.labelList, 'count'):
                            for i in range(main_window.labelList.count()):
                                item = main_window.labelList.item(i)
                                if item:
                                    try:
                                        if hasattr(item, 'shape'):
                                            shape = item.shape()
                                            if shape is not None:
                                                valid_shapes.append(shape)
                                    except Exception:
                                        pass
                        
                        if valid_shapes and hasattr(main_window, 'canvas') and hasattr(main_window.canvas, 'loadShapes'):
                            try:
                                main_window.canvas.loadShapes(valid_shapes)
                                print(f"[DEBUG] labelOrderChanged: 恢复后加载了 {len(valid_shapes)} 个有效 shapes")
                                # 确保 canvas 刷新
                                if hasattr(main_window.canvas, 'update'):
                                    main_window.canvas.update()
                                if hasattr(main_window.canvas, 'repaint'):
                                    main_window.canvas.repaint()
                            except Exception as e2:
                                print(f"⚠ 恢复 loadShapes 失败: {e2}")
                else:
                    # 其他 AttributeError，重新抛出
                    raise
                    
        except Exception as e:
            # 捕获所有其他异常，避免崩溃
            error_str = str(e)
            if "'NoneType' object has no attribute 'copy'" in error_str:
                print(f"⚠ 拦截到 None shape 错误: {error_str}，已自动处理")
                # 同步 labelList 和 canvas.shapes
                _sync_label_list_with_canvas(main_window)
                # 确保 canvas 刷新
                if hasattr(main_window, 'canvas'):
                    if hasattr(main_window.canvas, 'update'):
                        main_window.canvas.update()
                    if hasattr(main_window.canvas, 'repaint'):
                        main_window.canvas.repaint()
            else:
                print(f"⚠ labelOrderChanged 发生错误: {e}")
                import traceback
                traceback.print_exc()
    
    # 替换方法
    main_window.labelOrderChanged = new_label_order_changed


def _hook_open_dir(main_window, plugin_widget):
    """
    拦截 Labelme 的 openDir 方法，在打开路径时检查并创建目录结构
    如果没有 images 和 annotations 路径，则执行批量导入自动生成
    
    Args:
        main_window: Labelme 主窗口实例
        plugin_widget: 训练插件实例（用于调用检查目录结构的方法）
    """
    # 保存原始的 openDir 方法
    if not hasattr(main_window, '_original_open_dir'):
        if hasattr(main_window, 'openDir'):
            main_window._original_open_dir = main_window.openDir
        elif hasattr(main_window, 'open_dir'):
            main_window._original_open_dir = main_window.open_dir
        else:
            print("⚠ 警告: 无法找到 openDir 方法")
            return
    
    def new_open_dir(dir_path=None):
        """
        增强的 openDir 方法，在打开路径时检查并创建目录结构
        如果没有提供路径，默认指向 paddleocr/dataset
        """
        # 如果没有提供路径，默认指向 paddleocr/dataset
        if not dir_path:
            from PyQt5.QtWidgets import QFileDialog
            # 获取项目根目录
            plugin_file = Path(__file__).resolve()
            project_root = plugin_file.parent.parent.parent
            default_dataset_dir = project_root / 'paddleocr' / 'dataset'
            
            # 如果路径不存在，创建它
            if not default_dataset_dir.exists():
                try:
                    default_dataset_dir.mkdir(parents=True, exist_ok=True)
                    print(f"✓ 自动创建数据集目录: {default_dataset_dir}")
                except Exception as e:
                    print(f"⚠ 创建数据集目录失败: {e}")
            
            # 显示文件对话框，默认指向 paddleocr/dataset
            # 使用绝对路径，确保对话框能正确跳转到该路径
            default_path = str(default_dataset_dir.resolve())
            print(f"[DEBUG] 打开文件对话框，默认路径: {default_path}")
            
            selected_dir = QFileDialog.getExistingDirectory(
                main_window,
                "选择要打开的目录",
                default_path,  # 使用绝对路径，确保对话框跳转到该路径
                QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
            )
            
            if selected_dir:
                dir_path = selected_dir
                print(f"[DEBUG] 用户选择的目录: {dir_path}")
            else:
                # 用户取消了选择，直接返回
                print(f"[DEBUG] 用户取消了目录选择")
                return
        
        # 如果提供了路径，检查并创建目录结构
        if dir_path:
            dir_path_obj = Path(dir_path)
            
            # 判断是 images 目录还是数据集根目录
            dataset_dir = None
            if dir_path_obj.name == 'images':
                # 如果是 images 目录，使用父目录作为数据集目录
                dataset_dir = dir_path_obj.parent
            else:
                # 可能是数据集根目录，检查是否有 images 子目录
                images_dir = dir_path_obj / 'images'
                if images_dir.exists():
                    dataset_dir = dir_path_obj
                else:
                    # 可能是普通目录，使用当前目录作为数据集目录
                    dataset_dir = dir_path_obj
            
            # 检查并创建目录结构
            if dataset_dir and hasattr(plugin_widget, '_check_and_create_dataset_structure'):
                try:
                    if plugin_widget._check_and_create_dataset_structure(dataset_dir):
                        # 如果创建成功，确保打开的是 images 目录
                        images_dir = dataset_dir / 'images'
                        if images_dir.exists():
                            # 使用 images 目录打开（LABELME 需要打开包含图片的目录）
                            dir_path = str(images_dir)
                except Exception as e:
                    print(f"⚠ 检查目录结构时出错: {e}")
            
            # 如果选择的是数据集根目录，自动打开 images 子目录
            # 这样 LABELME 才能正常加载图片和标注
            if dir_path:
                dir_path_obj = Path(dir_path)
                # 如果是数据集根目录（不是 images 目录），尝试打开 images 子目录
                if dir_path_obj.name != 'images':
                    images_dir = dir_path_obj / 'images'
                    if images_dir.exists():
                        dir_path = str(images_dir)
        
        # 调用原始的 openDir 方法，按照原来的方式加载图片和标注信息
        # 确保 dir_path 不为空
        if not dir_path:
            return None
        
        # 在调用 openDir 之前，先 Hook 文件列表更新方法，在扫描时直接排除 tmp 目录
        _hook_file_list_update(main_window)
        
        # 也尝试 Hook 文件扫描方法（如果存在）
        _hook_file_scanning(main_window, dir_path)
        
        try:
            if hasattr(main_window, '_original_open_dir'):
                result = main_window._original_open_dir(dir_path)
            else:
                # 如果没有原始方法，尝试直接调用
                if hasattr(main_window, 'openDir'):
                    result = main_window.openDir(dir_path)
                elif hasattr(main_window, 'open_dir'):
                    result = main_window.open_dir(dir_path)
                else:
                    result = None
            
            # 打开目录后，延迟过滤文件列表（排除 Mac 的 ._ 文件和 tmp 目录中的文件）
            # 使用 QTimer 延迟执行，确保文件列表已完全加载（作为备用方案）
            QTimer.singleShot(200, lambda: _filter_file_list(main_window, dir_path))
            
            # 打开目录后，重新加载类别列表（从 COCO 文件）
            _load_categories_from_coco(main_window)
            
            return result
        except Exception as e:
            print(f"⚠ 调用 openDir 失败: {e}")
            import traceback
            traceback.print_exc()
            return None
    
    # 替换 openDir 方法
    if hasattr(main_window, 'openDir'):
        main_window.openDir = new_open_dir
    if hasattr(main_window, 'open_dir'):
        main_window.open_dir = new_open_dir


def _hook_file_dialog(main_window):
    """
    直接 Hook QFileDialog.getExistingDirectory，确保所有文件对话框默认指向 paddleocr/dataset
    
    Args:
        main_window: Labelme 主窗口实例
    """
    from PyQt5.QtWidgets import QFileDialog
    
    # 保存原始的 getExistingDirectory 方法
    if not hasattr(QFileDialog, '_original_getExistingDirectory'):
        QFileDialog._original_getExistingDirectory = QFileDialog.getExistingDirectory
        
        @staticmethod
        def new_getExistingDirectory(parent=None, caption='', directory='', options=QFileDialog.ShowDirsOnly):
            """
            增强的 getExistingDirectory 方法，默认指向 paddleocr/dataset
            """
            # 获取项目根目录
            plugin_file = Path(__file__).resolve()
            project_root = plugin_file.parent.parent.parent
            default_dataset_dir = project_root / 'paddleocr' / 'dataset'
            
            # 判断是否需要使用默认路径
            # 如果 directory 为空、'.'、当前工作目录，或者是 LABELME 的默认路径，都使用我们的默认路径
            use_default = False
            if not directory or directory == '':
                use_default = True
            elif directory == '.':
                use_default = True
            else:
                try:
                    dir_path = Path(directory)
                    # 如果是当前工作目录，使用默认路径
                    if dir_path.resolve() == Path(os.getcwd()).resolve():
                        use_default = True
                    # 如果路径不存在，也使用默认路径（可能是无效路径）
                    elif not dir_path.exists():
                        use_default = True
                except:
                    # 如果路径解析失败，使用默认路径
                    use_default = True
            
            if use_default:
                # 如果路径不存在，创建它
                if not default_dataset_dir.exists():
                    try:
                        default_dataset_dir.mkdir(parents=True, exist_ok=True)
                        print(f"✓ 自动创建数据集目录: {default_dataset_dir}")
                    except Exception as e:
                        print(f"⚠ 创建数据集目录失败: {e}")
                
                directory = str(default_dataset_dir.resolve())
                print(f"[DEBUG] 文件对话框默认路径: {directory}")
            
            # 调用原始方法，确保返回正确的值
            try:
                result = QFileDialog._original_getExistingDirectory(parent, caption, directory, options)
                return result
            except Exception as e:
                print(f"⚠ 文件对话框出错: {e}")
                import traceback
                traceback.print_exc()
                # 如果出错，尝试使用原始参数调用
                return QFileDialog._original_getExistingDirectory(parent, caption, directory, options)
        
        # 替换方法
        QFileDialog.getExistingDirectory = new_getExistingDirectory
        print("✓ Hook QFileDialog.getExistingDirectory 成功")


def _hook_open_dir_menu(main_window):
    """
    Hook LABELME 的 Open Dir 菜单项，确保文件对话框默认指向 paddleocr/dataset
    
    Args:
        main_window: Labelme 主窗口实例
    """
    try:
        # 获取菜单栏
        menubar = main_window.menuBar()
        if not menubar:
            return
        
        # 查找 File 菜单
        file_menu = None
        for action in menubar.actions():
            menu_text = action.text().replace('&', '')  # 移除快捷键标记
            if menu_text.lower() in ['file', '文件']:
                file_menu = action.menu()
                break
        
        if not file_menu:
            return
        
        # 查找 Open Dir 菜单项
        open_dir_action = None
        for action in file_menu.actions():
            action_text = action.text().replace('&', '').lower()
            if 'open' in action_text and ('dir' in action_text or '目录' in action_text or '文件夹' in action_text):
                open_dir_action = action
                break
        
        if open_dir_action:
            # 不破坏原始连接，只是添加我们的逻辑
            # 因为我们已经 hook 了 openDir 方法，菜单项调用 openDir 时会自动使用默认路径
            pass  # OpenDir方法已被hook，会自动使用paddleocr/dataset作为默认路径
    except Exception as e:
        print(f"⚠ Hook Open Dir 菜单项失败: {e}")
        import traceback
        traceback.print_exc()


def register_plugin(main_window):
    """
    注册插件到 Labelme
    
    Args:
        main_window: Labelme 主窗口实例
        
    Returns:
        插件实例
    """
    if QWidget is None:
        print("警告: PyQt5 未安装，无法加载训练插件")
        return None
    
    try:
        # 检查是否已经加载过插件（避免重复加载）
        if hasattr(main_window, '_training_plugin_loaded'):
            return main_window._training_plugin_widget
        
        # 创建插件界面
        plugin_widget = TrainingPluginWidget(main_window)
        
        # 创建 Dock Widget
        dock = QDockWidget("训练管理", main_window)
        dock.setObjectName("TrainingPluginDock")  # 设置 objectName 避免警告
        dock.setWidget(plugin_widget)
        dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea)
        
        # 添加到主窗口
        main_window.addDockWidget(Qt.RightDockWidgetArea, dock)
        
        # 拦截 loadFile 方法，支持从 annotations 目录加载标注
        _hook_load_file(main_window)
        
        # 拦截 saveFile 方法，支持保存到 annotations 目录
        _hook_save_file(main_window)
        
        # 拦截 canvas.loadShapes 方法，过滤掉 None 的 shape（最关键的拦截点）
        _hook_canvas_load_shapes(main_window)
        
        # 拦截 shapeSelectionChanged 方法，防止访问 None shape
        _hook_shape_selection_changed(main_window)
        
        # 拦截 labelOrderChanged 方法，防止访问 None shape（删除标注时调用）
        _hook_label_order_changed(main_window)
        
        # 拦截 openDir 方法，在打开路径时检查并创建目录结构（优先使用这个）
        _hook_open_dir(main_window, plugin_widget)
        
        # Hook 菜单项，确保 Open Dir 菜单也使用默认路径
        _hook_open_dir_menu(main_window)
        
        # Hook 文件对话框，确保所有文件对话框默认指向 paddleocr/dataset
        # 这个很重要，因为 LABELME 的 openDir 内部可能直接调用 QFileDialog
        _hook_file_dialog(main_window)
        
        # Hook 文件列表更新方法，在扫描文件时直接排除 tmp 目录（提前执行）
        _hook_file_list_update(main_window)
        
        # 初始化时加载类别列表（从 COCO 文件）
        _load_categories_from_coco(main_window)
        
        # 标记已加载
        main_window._training_plugin_loaded = True
        main_window._training_plugin_widget = plugin_widget
        
        print("✓ 训练插件已加载（支持分离目录标注自动加载和保存）")
        return plugin_widget
        
    except Exception as e:
        print(f"✗ 加载训练插件失败: {e}")
        import traceback
        traceback.print_exc()
        return None


# 兼容性：如果直接运行，可以测试插件
if __name__ == '__main__':
    from PyQt5.QtWidgets import QApplication, QMainWindow
    
    app = QApplication(sys.argv)
    window = QMainWindow()
    window.setWindowTitle("训练插件测试")
    window.resize(800, 600)
    
    plugin = register_plugin(window)
    
    window.show()
    sys.exit(app.exec_())


